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.
This commit is contained in:
2026-08-13 23:10:22 +07:00
parent 96944ad2c3
commit f72aaa2329
94 changed files with 7758 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
*.user
.vs/
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
.vs/
.idea/
.DS_Store
.claude/
.serena/
+76
View File
@@ -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 <Name> --project Mws.Infrastructure --startup-project Mws.Api
# apply migrations explicitly (otherwise the app does it on boot)
dotnet ef database update --project Mws.Infrastructure --startup-project Mws.Api
# 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<T>`.
### Authorization model
Two independent mechanisms — don't cross-wire them:
1. **Project membership** (unchanged). JWT carries `sub` = user `Guid`. Every protected project/task/document endpoint looks up `ProjectMember` to verify the caller belongs to the project — checks live in the application service (`GetProjectForUserAsync`, `EnsureMemberAccessAsync`, `GetMemberRoleAsync`). Mutating project settings requires `MemberRole.Owner`. Add a new check the same way.
2. **Screen-level role permissions** (admin screens only: Accounts, Roles). `Role` is a DB entity (`roles` table), not an enum — a user's `User.RoleId` points to one. `RolePermission` is a per-(role, screen) CRUD matrix (`CanView`/`CanCreate`/`CanEdit`/`CanDelete`). `IPermissionService.EnsureAsync(userId, screen, action)` (in `Mws.Application/Permissions/PermissionService.cs`) is the gate — called at the top of `AccountService`/`RoleService` methods and inline in `RolesController`. There is **no** `[Authorize(Roles=...)]` JWT-claim-based gating — the JWT only carries `sub`/`name`; role/permission is always resolved fresh from the DB per request, so changing a role's permissions or a user's role takes effect immediately without re-login. `ScreenCatalog.cs` is the single source of truth for valid screen keys (`dashboard`, `projects`, `tasks`, `documents`, `accounts`, `roles`) — adding a new admin-gated screen means adding it there. `GET /api/menu` returns the CRUD matrix per screen for the caller's role so the frontend can render nav + gate buttons; it does not itself enforce anything server-side.
### 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`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>` on every project.
- DTOs live next to the service that returns them (e.g. `ProjectDto` in `Projects/Contracts.cs`). Use `Contracts.cs` per folder; don't sprawl.
- 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.
+19
View File
@@ -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"]
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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");
}
}
+43
View File
@@ -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<ActionResult<List<AccountDto>>> GetAll([FromQuery] string? q, CancellationToken ct)
{
return Ok(await accountService.GetAccountsAsync(User.GetUserId(), q, ct));
}
[HttpPost]
public async Task<ActionResult<AccountDto>> Create([FromBody] CreateAccountRequest request, CancellationToken ct)
{
return Ok(await accountService.CreateAccountAsync(User.GetUserId(), request, ct));
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<AccountDto>> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct)
{
return Ok(await accountService.UpdateAccountAsync(User.GetUserId(), id, request, ct));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await accountService.DeleteAccountAsync(User.GetUserId(), id, ct);
return NoContent();
}
[HttpPost("{id:guid}/reset-password")]
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct)
{
await accountService.ResetPasswordAsync(User.GetUserId(), id, request, ct);
return NoContent();
}
}
+18
View File
@@ -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<ActionResult<LoginResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
{
var response = await authService.LoginAsync(request, ct);
return Ok(response);
}
}
@@ -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<ActionResult<List<DocumentNodeDto>>> 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<ActionResult<List<DocumentNodeDto>>> GetTree(Guid projectId, CancellationToken ct)
{
return Ok(await documentService.GetTreeAsync(User.GetUserId(), projectId, ct));
}
[HttpPost("projects/{projectId:guid}/documents")]
public async Task<ActionResult<DocumentDto>> 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<ActionResult<DocumentDto>> Get(Guid id, CancellationToken ct)
{
return Ok(await documentService.GetAsync(User.GetUserId(), id, ct));
}
[HttpPut("documents/{id:guid}")]
public async Task<ActionResult<DocumentDto>> 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<ActionResult<DocumentDto>> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{
await documentService.DeleteAsync(User.GetUserId(), id, ct);
return NoContent();
}
}
+17
View File
@@ -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<ActionResult<List<MenuItemDto>>> GetMenu(CancellationToken ct)
{
return Ok(await permissions.GetMenuAsync(User.GetUserId(), ct));
}
}
@@ -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<ActionResult<List<ProjectMemberDto>>> GetAll(Guid projectId, CancellationToken ct)
{
return Ok(await memberService.GetMembersAsync(User.GetUserId(), projectId, ct));
}
[HttpPost]
public async Task<ActionResult<ProjectMemberDto>> 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<ActionResult<ProjectMemberDto>> 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<IActionResult> Remove(Guid projectId, Guid userId, CancellationToken ct)
{
await memberService.RemoveMemberAsync(User.GetUserId(), projectId, userId, ct);
return NoContent();
}
}
+55
View File
@@ -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<ActionResult<List<ProjectDto>>> GetAll(CancellationToken ct)
{
return Ok(await projectService.GetProjectsAsync(User.GetUserId(), ct));
}
[HttpGet("search")]
public async Task<ActionResult<List<ProjectDto>>> Search([FromQuery] string? q, CancellationToken ct)
{
return Ok(await projectService.SearchAsync(User.GetUserId(), q ?? string.Empty, ct));
}
[HttpGet("{projectId:guid}")]
public async Task<ActionResult<ProjectDto>> Get(Guid projectId, CancellationToken ct)
{
return Ok(await projectService.GetProjectAsync(User.GetUserId(), projectId, ct));
}
[HttpGet("{projectId:guid}/overview")]
public async Task<ActionResult<ProjectOverviewDto>> Overview(Guid projectId, CancellationToken ct)
{
return Ok(await projectService.GetOverviewAsync(User.GetUserId(), projectId, ct));
}
[HttpPost]
public async Task<ActionResult<ProjectDto>> 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<ActionResult<ProjectDto>> Update(Guid projectId, [FromBody] UpdateProjectRequest request, CancellationToken ct)
{
return Ok(await projectService.UpdateProjectAsync(User.GetUserId(), projectId, request, ct));
}
[HttpDelete("{projectId:guid}")]
public async Task<IActionResult> Delete(Guid projectId, CancellationToken ct)
{
await projectService.ArchiveProjectAsync(User.GetUserId(), projectId, ct);
return NoContent();
}
}
+48
View File
@@ -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<ActionResult<List<RoleDto>>> GetAll(CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.View, ct);
return Ok(await roleService.GetRolesAsync(ct));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<RoleDto>> 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<ActionResult<RoleDto>> 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<ActionResult<RoleDto>> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{
await permissions.EnsureAsync(User.GetUserId(), "roles", PermissionAction.Delete, ct);
await roleService.DeleteRoleAsync(id, ct);
return NoContent();
}
}
+68
View File
@@ -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<ActionResult<List<TaskDto>>> 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<ActionResult<List<TaskDto>>> GetAll(
Guid projectId,
[FromQuery] string? status,
[FromQuery] string? priority,
[FromQuery] Guid? assigneeId,
CancellationToken ct)
{
var statusValue = ParseOptional<TaskStatus>(status);
var priorityValue = ParseOptional<TaskPriority>(priority);
return Ok(await taskService.GetTasksAsync(User.GetUserId(), projectId, statusValue, priorityValue, assigneeId, ct));
}
[HttpPost("projects/{projectId:guid}/tasks")]
public async Task<ActionResult<TaskDto>> 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<ActionResult<TaskDto>> Get(Guid id, CancellationToken ct)
{
return Ok(await taskService.GetAsync(User.GetUserId(), id, ct));
}
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<TaskDto>> 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<IActionResult> Delete(Guid id, CancellationToken ct)
{
await taskService.DeleteAsync(User.GetUserId(), id, ct);
return NoContent();
}
private static TEnum? ParseOptional<TEnum>(string? value) where TEnum : struct, Enum
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return Enum.TryParse<TEnum>(value, ignoreCase: true, out var result) ? result : throw new BadRequestException($"Invalid {typeof(TEnum).Name}: {value}");
}
}
+20
View File
@@ -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<ActionResult<List<UserDto>>> GetAll([FromQuery] string? q, CancellationToken ct)
{
var users = await uow.Users.SearchWithRoleAsync(q, 50, ct);
return Ok(mapper.Map<List<UserDto>>(users));
}
}
@@ -0,0 +1,46 @@
using System.Text.Json;
using Mws.Application.Common;
namespace Mws.Api.Middleware;
public class ApiExceptionMiddleware(RequestDelegate next, ILogger<ApiExceptionMiddleware> 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<ApiExceptionMiddleware>();
}
+122
View File
@@ -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<AppDbContext>();
await db.Database.MigrateAsync();
await DbSeeder.SeedAsync(db, scope.ServiceProvider.GetRequiredService<IPasswordHasher>());
}
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();
+23
View File
@@ -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"
}
}
}
}
+22
View File
@@ -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": "*"
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>mws.backend.dotnet</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\mws.application\mws.application.csproj" />
<ProjectReference Include="..\mws.domain\mws.domain.csproj" />
<ProjectReference Include="..\mws.infrastructure\mws.infrastructure.csproj" />
</ItemGroup>
</Project>
+116
View File
@@ -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<List<AccountDto>> 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<List<AccountDto>>(users);
}
public async Task<AccountDto> 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<AccountDto>(user);
}
public async Task<AccountDto> 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<AccountDto>(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);
}
}
+32
View File
@@ -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; }
}
@@ -0,0 +1,10 @@
namespace Mws.Application.Accounts;
public interface IAccountService
{
Task<List<AccountDto>> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default);
Task<AccountDto> CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default);
Task<AccountDto> 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);
}
+35
View File
@@ -0,0 +1,35 @@
using AutoMapper;
using Mws.Application.Common;
namespace Mws.Application.Auth;
public interface IAuthService
{
Task<LoginResponse> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
}
public class AuthService(IUnitOfWork uow, IPasswordHasher passwordHasher, ITokenService tokenService, IMapper mapper)
: IAuthService
{
public async Task<LoginResponse> 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<UserDto>(user),
};
}
}
+33
View File
@@ -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);
}
+9
View File
@@ -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);
+14
View File
@@ -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<int> SaveChangesAsync(CancellationToken ct = default);
}
+33
View File
@@ -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<Domain.Projects.Project, ProjectDto>();
CreateMap<Role, RoleDto>();
CreateMap<RolePermission, PermissionEntryDto>();
CreateMap<User, AccountDto>();
CreateMap<User, UserDto>();
CreateMap<Document, DocumentDto>();
CreateMap<Document, DocumentNodeDto>()
.ForMember(d => d.Children, opt => opt.Ignore());
CreateMap<TaskItem, TaskDto>()
.ForMember(d => d.AssigneeName, opt => opt.MapFrom(s => s.Assignee == null ? null : s.Assignee.DisplayName));
CreateMap<TaskItem, RecentTaskDto>()
.ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString()));
CreateMap<Document, RecentDocumentDto>();
}
}
@@ -0,0 +1,15 @@
using Mws.Domain.Documents;
namespace Mws.Application.Common.Repositories;
public interface IDocumentRepository : IRepository<Document>
{
Task<Document?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<Document>> GetTreeForProjectAsync(Guid projectId, CancellationToken ct = default);
Task<Document?> GetInProjectAsync(Guid parentId, Guid projectId, CancellationToken ct = default);
Task<Guid?> GetParentIdAsync(Guid documentId, CancellationToken ct = default);
Task<List<Document>> GetChildrenAsync(Guid parentId, CancellationToken ct = default);
Task<List<Document>> SearchAsync(List<Guid> projectIds, string? term, int take, CancellationToken ct = default);
Task<int> CountByTypeForProjectAsync(Guid projectId, DocumentType type, CancellationToken ct = default);
Task<List<Document>> GetRecentForProjectAsync(Guid projectId, DocumentType type, int take, CancellationToken ct = default);
}
@@ -0,0 +1,28 @@
using Mws.Domain.Projects;
namespace Mws.Application.Common.Repositories;
public interface IProjectRepository : IRepository<Project>
{
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<List<Project>> GetForUserAsync(Guid userId, CancellationToken ct = default);
Task<List<Project>> SearchForUserAsync(Guid userId, string? term, CancellationToken ct = default);
Task<int> CountMembersAsync(Guid projectId, CancellationToken ct = default);
Task<bool> IsMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
Task<MemberRole?> GetMemberRoleAsync(Guid projectId, Guid userId, CancellationToken ct = default);
Task<ProjectMember?> GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default);
Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default);
Task<List<ProjectMember>> GetMembersWithUserAsync(Guid projectId, CancellationToken ct = default);
Task<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default);
Task<List<Guid>> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default);
Task<List<Guid>> GetOwnedProjectIdsAsync(Guid userId, CancellationToken ct = default);
Task<List<Guid>> GetDocumentViewableProjectIdsAsync(Guid userId, CancellationToken ct = default);
void AddMember(ProjectMember member);
void RemoveMember(ProjectMember member);
Task<ProjectMemberPermission?> GetMemberPermissionAsync(Guid projectId, Guid userId, string screen, CancellationToken ct = default);
Task<Dictionary<Guid, ProjectMemberPermission>> GetMemberPermissionsAsync(Guid projectId, string screen, CancellationToken ct = default);
void AddMemberPermission(ProjectMemberPermission permission);
}
@@ -0,0 +1,7 @@
namespace Mws.Application.Common.Repositories;
public interface IRepository<in T> where T : class
{
void Add(T entity);
void Remove(T entity);
}
@@ -0,0 +1,14 @@
using Mws.Domain.Roles;
namespace Mws.Application.Common.Repositories;
public interface IRoleRepository : IRepository<Role>
{
Task<List<Role>> GetAllWithPermissionsAsync(CancellationToken ct = default);
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default);
Task<Role?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<bool> ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default);
Task<RolePermission?> GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default);
Task<Dictionary<string, RolePermission>> GetPermissionsAsync(Guid roleId, CancellationToken ct = default);
void RemovePermissions(IEnumerable<RolePermission> permissions);
}
@@ -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<TaskItem>
{
Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<TaskItem?> GetWithAssigneeAsync(Guid id, CancellationToken ct = default);
Task<List<TaskItem>> GetForProjectAsync(
Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default);
Task<List<TaskItem>> SearchWithAssigneeAsync(List<Guid> projectIds, string? term, int take, CancellationToken ct = default);
Task<Dictionary<TaskStatus, int>> CountByStatusForProjectAsync(Guid projectId, CancellationToken ct = default);
Task<List<TaskItem>> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using Mws.Domain.Users;
namespace Mws.Application.Common.Repositories;
public interface IUserRepository : IRepository<User>
{
Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<User?> GetByIdWithRoleAsync(Guid id, CancellationToken ct = default);
Task<User?> GetByUsernameWithRoleAsync(string username, CancellationToken ct = default);
Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default);
Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default);
Task<Guid> GetRoleIdAsync(Guid userId, CancellationToken ct = default);
Task<List<User>> SearchWithRoleAsync(string? term, int? take, CancellationToken ct = default);
}
+49
View File
@@ -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<DocumentNodeDto> Children { get; set; } = [];
}
@@ -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<List<DocumentNodeDto>> 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<DocumentNodeDto>(d));
var roots = new List<DocumentNodeDto>();
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<DocumentDto> GetAsync(Guid userId, Guid documentId, CancellationToken ct = default)
{
var doc = await GetDocumentForUserAsync(userId, documentId, ct);
return mapper.Map<DocumentDto>(doc);
}
public async Task<DocumentDto> 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<DocumentDto>(doc);
}
public async Task<DocumentDto> 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<DocumentDto>(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<List<DocumentNodeDto>> 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<List<DocumentNodeDto>>(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<bool> 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<Document> 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;
}
}
@@ -0,0 +1,14 @@
using Mws.Application.Common;
namespace Mws.Application.Documents;
public interface IDocumentService
{
Task<List<DocumentNodeDto>> GetTreeAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<DocumentDto> GetAsync(Guid userId, Guid documentId, CancellationToken ct = default);
Task<DocumentDto> CreateAsync(Guid userId, Guid projectId, CreateDocumentRequest request, CancellationToken ct = default);
Task<DocumentDto> 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<List<DocumentNodeDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
}
+20
View File
@@ -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; }
}
@@ -0,0 +1,7 @@
namespace Mws.Application.Permissions;
public interface IPermissionService
{
Task<List<MenuItemDto>> GetMenuAsync(Guid userId, CancellationToken ct = default);
Task EnsureAsync(Guid userId, string screen, PermissionAction action, CancellationToken ct = default);
}
@@ -0,0 +1,47 @@
using Mws.Application.Common;
namespace Mws.Application.Permissions;
public class PermissionService(IUnitOfWork uow) : IPermissionService
{
public async Task<List<MenuItemDto>> 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}");
}
}
}
@@ -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<ScreenDefinition> 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<string> Keys = Screens.Select(s => s.Key).ToHashSet();
}
+77
View File
@@ -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<string, int> TaskCountsByStatus { get; set; } = [];
public List<RecentTaskDto> RecentTasks { get; set; } = [];
public List<RecentDocumentDto> 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; }
}
@@ -0,0 +1,22 @@
using Mws.Application.Common;
namespace Mws.Application.Projects;
public interface IProjectService
{
Task<List<ProjectDto>> GetProjectsAsync(Guid userId, CancellationToken ct = default);
Task<ProjectDto> GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<ProjectDto> CreateProjectAsync(Guid userId, CreateProjectRequest request, CancellationToken ct = default);
Task<ProjectDto> UpdateProjectAsync(Guid userId, Guid projectId, UpdateProjectRequest request, CancellationToken ct = default);
Task ArchiveProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<ProjectOverviewDto> GetOverviewAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<List<ProjectDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
}
public interface IProjectMemberService
{
Task<List<ProjectMemberDto>> GetMembersAsync(Guid userId, Guid projectId, CancellationToken ct = default);
Task<ProjectMemberDto> AddMemberAsync(Guid userId, Guid projectId, AddMemberRequest request, CancellationToken ct = default);
Task<ProjectMemberDto> UpdateMemberDocumentPermissionsAsync(Guid userId, Guid projectId, Guid memberUserId, UpdateMemberDocumentPermissionsRequest request, CancellationToken ct = default);
Task RemoveMemberAsync(Guid userId, Guid projectId, Guid memberUserId, CancellationToken ct = default);
}
@@ -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<List<ProjectMemberDto>> 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<ProjectMemberDto> 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<ProjectMemberDto> 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<MemberRole?> GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
uow.Projects.GetMemberRoleAsync(projectId, userId, ct);
}
+127
View File
@@ -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<List<ProjectDto>> GetProjectsAsync(Guid userId, CancellationToken ct = default)
{
var projects = await uow.Projects.GetForUserAsync(userId, ct);
return mapper.Map<List<ProjectDto>>(projects);
}
public async Task<ProjectDto> GetProjectAsync(Guid userId, Guid projectId, CancellationToken ct = default)
{
var project = await GetProjectForUserAsync(userId, projectId, ct);
return mapper.Map<ProjectDto>(project);
}
public async Task<ProjectDto> 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<ProjectDto>(project);
}
public async Task<ProjectDto> 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<ProjectDto>(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<ProjectOverviewDto> 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<ProjectDto>(project),
MemberCount = memberCount,
DocumentCount = documentCount,
TaskCountsByStatus = taskCountsByStatus,
RecentTasks = mapper.Map<List<RecentTaskDto>>(recentTasks),
RecentDocuments = mapper.Map<List<RecentDocumentDto>>(recentDocuments),
};
}
public async Task<List<ProjectDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default)
{
var projects = await uow.Projects.SearchForUserAsync(userId, term, ct);
return mapper.Map<List<ProjectDto>>(projects);
}
protected async Task<Project> GetProjectForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default)
{
return await uow.Projects.GetForUserAsync(userId, projectId, ct)
?? throw new NotFoundException("Project not found");
}
protected Task<MemberRole?> GetMemberRoleAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
uow.Projects.GetMemberRoleAsync(projectId, userId, ct);
}
+24
View File
@@ -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<PermissionEntryDto> Permissions { get; set; } = [];
}
public class SaveRoleRequest
{
public string Name { get; set; } = string.Empty;
public List<PermissionEntryDto> Permissions { get; set; } = [];
}
+10
View File
@@ -0,0 +1,10 @@
namespace Mws.Application.Roles;
public interface IRoleService
{
Task<List<RoleDto>> GetRolesAsync(CancellationToken ct = default);
Task<RoleDto> GetRoleAsync(Guid id, CancellationToken ct = default);
Task<RoleDto> CreateRoleAsync(SaveRoleRequest request, CancellationToken ct = default);
Task<RoleDto> UpdateRoleAsync(Guid id, SaveRoleRequest request, CancellationToken ct = default);
Task DeleteRoleAsync(Guid id, CancellationToken ct = default);
}
+118
View File
@@ -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<List<RoleDto>> GetRolesAsync(CancellationToken ct = default)
{
var roles = await uow.Roles.GetAllWithPermissionsAsync(ct);
return mapper.Map<List<RoleDto>>(roles);
}
public async Task<RoleDto> GetRoleAsync(Guid id, CancellationToken ct = default)
{
var role = await uow.Roles.GetByIdWithPermissionsAsync(id, ct)
?? throw new NotFoundException("Role not found");
return mapper.Map<RoleDto>(role);
}
public async Task<RoleDto> 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<RoleDto>(role);
}
public async Task<RoleDto> 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<RoleDto>(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<RolePermission> BuildPermissions(List<PermissionEntryDto> 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();
}
}
+40
View File
@@ -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; }
}
+15
View File
@@ -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<List<TaskDto>> GetTasksAsync(Guid userId, Guid projectId, TaskStatus? status, TaskPriority? priority, Guid? assigneeId, CancellationToken ct = default);
Task<TaskDto> GetAsync(Guid userId, Guid taskId, CancellationToken ct = default);
Task<TaskDto> CreateAsync(Guid userId, Guid projectId, CreateTaskRequest request, CancellationToken ct = default);
Task<TaskDto> UpdateAsync(Guid userId, Guid taskId, UpdateTaskRequest request, CancellationToken ct = default);
Task DeleteAsync(Guid userId, Guid taskId, CancellationToken ct = default);
Task<List<TaskDto>> SearchAsync(Guid userId, string term, CancellationToken ct = default);
}
+129
View File
@@ -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<List<TaskDto>> 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<List<TaskDto>>(tasks);
}
public async Task<TaskDto> GetAsync(Guid userId, Guid taskId, CancellationToken ct = default)
{
var task = await GetTaskForUserAsync(userId, taskId, ct);
return await GetDtoAsync(task.Id, ct);
}
public async Task<TaskDto> 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<TaskDto> 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<List<TaskDto>> 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<List<TaskDto>>(tasks);
}
private async Task<TaskDto> GetDtoAsync(Guid id, CancellationToken ct)
{
var task = await uow.Tasks.GetWithAssigneeAsync(id, ct)
?? throw new NotFoundException("Task not found");
return mapper.Map<TaskDto>(task);
}
private async Task<TaskItem> 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<bool> IsMemberAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
uow.Projects.IsMemberAsync(projectId, userId, ct);
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\mws.domain\mws.domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0" />
</ItemGroup>
</Project>
+34
View File
@@ -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
+24
View File
@@ -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<Document> Children { get; set; } = [];
}
+19
View File
@@ -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<ProjectMember> Members { get; set; } = [];
}
+17
View File
@@ -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!;
}
@@ -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; }
}
+12
View File
@@ -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<RolePermission> Permissions { get; set; } = [];
}
+13
View File
@@ -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!;
}
+33
View File
@@ -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; }
}
+17
View File
@@ -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!;
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -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);
}
@@ -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);
}
@@ -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<Claim>
{
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);
}
}
+53
View File
@@ -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<AppDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddAutoMapper(cfg => { }, typeof(MappingProfile).Assembly);
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
var jwtSection = configuration.GetSection("Jwt");
var jwtOptions = jwtSection.Get<JwtOptions>() ?? 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<JwtOptions>(jwtSection.Bind);
services.AddSingleton(jwtOptions);
services.AddScoped<ITokenService, JwtTokenService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IPermissionService, PermissionService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<IProjectMemberService, ProjectMemberService>();
services.AddScoped<IDocumentService, DocumentService>();
services.AddScoped<ITaskService, TaskService>();
return services;
}
}
@@ -0,0 +1,264 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}
@@ -0,0 +1,180 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mws.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "documents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
Content = table.Column<string>(type: "text", nullable: true),
Type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uuid", nullable: true),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
Username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
PasswordHash = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Priority = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
AssigneeId = table.Column<Guid>(type: "uuid", nullable: true),
DueDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedBy = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,353 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}
@@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mws.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRolesAndPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "users",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<Guid>(
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<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
Screen = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CanView = table.Column<bool>(type: "boolean", nullable: false),
CanCreate = table.Column<bool>(type: "boolean", nullable: false),
CanEdit = table.Column<bool>(type: "boolean", nullable: false),
CanDelete = table.Column<bool>(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);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,365 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("CanCreateDocuments")
.HasColumnType("boolean");
b.Property<bool>("CanDeleteDocuments")
.HasColumnType("boolean");
b.Property<bool>("CanEditDocuments")
.HasColumnType("boolean");
b.Property<bool>("CanViewDocuments")
.HasColumnType("boolean");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mws.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMemberDocumentPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanCreateDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanDeleteDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanEditDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
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';");
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,391 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("ProjectId", "UserId", "Screen");
b.ToTable("project_member_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Roles.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}
@@ -0,0 +1,105 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mws.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SplitProjectMemberPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "project_member_permissions",
columns: table => new
{
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Screen = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CanView = table.Column<bool>(type: "boolean", nullable: false),
CanCreate = table.Column<bool>(type: "boolean", nullable: false),
CanEdit = table.Column<bool>(type: "boolean", nullable: false),
CanDelete = table.Column<bool>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanCreateDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanDeleteDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanEditDocuments",
table: "project_members",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
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");
}
}
}
@@ -0,0 +1,388 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("projects", (string)null);
});
modelBuilder.Entity("Mws.Domain.Projects.ProjectMember", b =>
{
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("ProjectId", "UserId", "Screen");
b.ToTable("project_member_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Roles.Role", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("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<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("Screen")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("CanCreate")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanEdit")
.HasColumnType("boolean");
b.Property<bool>("CanView")
.HasColumnType("boolean");
b.HasKey("RoleId", "Screen");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("Mws.Domain.Tasks.TaskItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<DateTime?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Priority")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}
@@ -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<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<ProjectMember> ProjectMembers => Set<ProjectMember>();
public DbSet<ProjectMemberPermission> ProjectMemberPermissions => Set<ProjectMemberPermission>();
public DbSet<Document> Documents => Set<Document>();
public DbSet<TaskItem> Tasks => Set<TaskItem>();
public DbSet<Role> Roles => Set<Role>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
@@ -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<Document>
{
public void Configure(EntityTypeBuilder<Document> e)
{
e.ToTable("documents");
e.HasKey(d => d.Id);
e.Property(d => d.Title).HasMaxLength(300).IsRequired();
e.Property(d => d.Type).HasConversion<string>().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);
}
}
@@ -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<Project>
{
public void Configure(EntityTypeBuilder<Project> 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<string>().HasMaxLength(20);
}
}
@@ -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<ProjectMember>
{
public void Configure(EntityTypeBuilder<ProjectMember> e)
{
e.ToTable("project_members");
e.HasKey(m => new { m.ProjectId, m.UserId });
e.Property(m => m.Role).HasConversion<string>().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);
}
}
@@ -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<ProjectMemberPermission>
{
public void Configure(EntityTypeBuilder<ProjectMemberPermission> 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<ProjectMember>()
.WithMany()
.HasForeignKey(p => new { p.ProjectId, p.UserId })
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -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<Role>
{
public void Configure(EntityTypeBuilder<Role> e)
{
e.ToTable("roles");
e.HasKey(r => r.Id);
e.Property(r => r.Name).HasMaxLength(100).IsRequired();
e.HasIndex(r => r.Name).IsUnique();
}
}
@@ -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<RolePermission>
{
public void Configure(EntityTypeBuilder<RolePermission> 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);
}
}
@@ -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<TaskItem>
{
public void Configure(EntityTypeBuilder<TaskItem> 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<string>().HasMaxLength(20);
e.Property(t => t.Priority).HasConversion<string>().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);
}
}
@@ -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<User>
{
public void Configure(EntityTypeBuilder<User> 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);
}
}
+178
View File
@@ -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 = "<h2>Authentication</h2><p>Phase 1 uses simple username/password with JWT.</p>",
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 = "<h2>User Management</h2><p>Users are created via seeding in Phase 1.</p>",
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 = "<h2>API Design</h2><p>REST API with ASP.NET Core Web API.</p>",
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();
}
}
@@ -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<Document>(db), IDocumentRepository
{
public Task<Document?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(d => d.Id == id, ct);
public Task<List<Document>> GetTreeForProjectAsync(Guid projectId, CancellationToken ct = default) =>
Set.Where(d => d.ProjectId == projectId).OrderBy(d => d.Title).ToListAsync(ct);
public Task<Document?> GetInProjectAsync(Guid parentId, Guid projectId, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(d => d.Id == parentId && d.ProjectId == projectId, ct);
public Task<Guid?> GetParentIdAsync(Guid documentId, CancellationToken ct = default) =>
Set.Where(d => d.Id == documentId).Select(d => d.ParentId).SingleAsync(ct);
public Task<List<Document>> GetChildrenAsync(Guid parentId, CancellationToken ct = default) =>
Set.Where(d => d.ParentId == parentId).ToListAsync(ct);
public Task<List<Document>> SearchAsync(List<Guid> 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<int> CountByTypeForProjectAsync(Guid projectId, DocumentType type, CancellationToken ct = default) =>
Set.CountAsync(d => d.ProjectId == projectId && d.Type == type, ct);
public Task<List<Document>> 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);
}
@@ -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<Project>(db), IProjectRepository
{
public Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(p => p.Id == id, ct);
public Task<Project?> GetForUserAsync(Guid userId, Guid projectId, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(p => p.Id == projectId && p.Members.Any(m => m.UserId == userId), ct);
public Task<List<Project>> GetForUserAsync(Guid userId, CancellationToken ct = default) =>
Set.Where(p => p.Members.Any(m => m.UserId == userId))
.OrderByDescending(p => p.UpdatedAt)
.ToListAsync(ct);
public Task<List<Project>> 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<int> CountMembersAsync(Guid projectId, CancellationToken ct = default) =>
Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId, ct);
public Task<bool> IsMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default) =>
Db.ProjectMembers.AnyAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
public Task<MemberRole?> 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<ProjectMember?> GetMemberAsync(Guid projectId, Guid userId, CancellationToken ct = default) =>
Db.ProjectMembers.FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
public Task<ProjectMember?> GetMemberWithUserAsync(Guid projectId, Guid userId, CancellationToken ct = default) =>
Db.ProjectMembers.Include(m => m.User).FirstOrDefaultAsync(m => m.ProjectId == projectId && m.UserId == userId, ct);
public Task<List<ProjectMember>> 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<int> CountOwnersAsync(Guid projectId, CancellationToken ct = default) =>
Db.ProjectMembers.CountAsync(m => m.ProjectId == projectId && m.Role == MemberRole.Owner, ct);
public Task<List<Guid>> GetProjectIdsForUserAsync(Guid userId, CancellationToken ct = default) =>
Set.Where(p => p.Members.Any(m => m.UserId == userId)).Select(p => p.Id).ToListAsync(ct);
public Task<List<Guid>> 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<List<Guid>> 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<ProjectMemberPermission?> 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<Dictionary<Guid, ProjectMemberPermission>> 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);
}
@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Mws.Application.Common.Repositories;
namespace Mws.Infrastructure.Persistence.Repositories;
public abstract class RepositoryBase<T>(AppDbContext db) : IRepository<T> where T : class
{
protected readonly AppDbContext Db = db;
protected DbSet<T> Set => Db.Set<T>();
public void Add(T entity) => Set.Add(entity);
public void Remove(T entity) => Set.Remove(entity);
}
@@ -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<Role>(db), IRoleRepository
{
public Task<List<Role>> GetAllWithPermissionsAsync(CancellationToken ct = default) =>
Set.Include(r => r.Permissions).OrderBy(r => r.Name).ToListAsync(ct);
public Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken ct = default) =>
Set.Include(r => r.Permissions).FirstOrDefaultAsync(r => r.Id == id, ct);
public Task<Role?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(r => r.Id == id, ct);
public Task<bool> ExistsByNameAsync(string name, Guid? excludeId, CancellationToken ct = default) =>
Set.AnyAsync(r => r.Name == name && (excludeId == null || r.Id != excludeId), ct);
public Task<RolePermission?> GetPermissionAsync(Guid roleId, string screen, CancellationToken ct = default) =>
Db.RolePermissions.FirstOrDefaultAsync(p => p.RoleId == roleId && p.Screen == screen, ct);
public Task<Dictionary<string, RolePermission>> GetPermissionsAsync(Guid roleId, CancellationToken ct = default) =>
Db.RolePermissions.Where(p => p.RoleId == roleId).ToDictionaryAsync(p => p.Screen, ct);
public void RemovePermissions(IEnumerable<RolePermission> permissions) => Db.RolePermissions.RemoveRange(permissions);
}
@@ -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<TaskItem>(db), ITaskRepository
{
public Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(t => t.Id == id, ct);
public Task<TaskItem?> GetWithAssigneeAsync(Guid id, CancellationToken ct = default) =>
Set.Include(t => t.Assignee).FirstOrDefaultAsync(t => t.Id == id, ct);
public Task<List<TaskItem>> 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<List<TaskItem>> SearchWithAssigneeAsync(List<Guid> 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<Dictionary<TaskStatus, int>> 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<List<TaskItem>> GetRecentForProjectAsync(Guid projectId, int take, CancellationToken ct = default) =>
Set.Where(t => t.ProjectId == projectId).OrderByDescending(t => t.UpdatedAt).Take(take).ToListAsync(ct);
}
@@ -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<User>(db), IUserRepository
{
public Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
Set.FirstOrDefaultAsync(u => u.Id == id, ct);
public Task<User?> GetByIdWithRoleAsync(Guid id, CancellationToken ct = default) =>
Set.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == id, ct);
public Task<User?> GetByUsernameWithRoleAsync(string username, CancellationToken ct = default) =>
Set.Include(u => u.Role).SingleOrDefaultAsync(u => u.Username == username, ct);
public Task<bool> ExistsByUsernameAsync(string username, CancellationToken ct = default) =>
Set.AnyAsync(u => u.Username == username, ct);
public Task<bool> ExistsByRoleIdAsync(Guid roleId, CancellationToken ct = default) =>
Set.AnyAsync(u => u.RoleId == roleId, ct);
public Task<Guid> GetRoleIdAsync(Guid userId, CancellationToken ct = default) =>
Set.Where(u => u.Id == userId).Select(u => u.RoleId).SingleOrDefaultAsync(ct);
public Task<List<User>> 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);
}
}
@@ -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<int> SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\mws.application\mws.application.csproj" />
<ProjectReference Include="..\mws.domain\mws.domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
</ItemGroup>
</Project>