From c23156c6b4cbfec2b00b9729fd3e889c128cbe2a Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Wed, 28 Jan 2026 12:58:28 -0500 Subject: [PATCH] feat(workspace): adds workspace and projects --- src/.idea/.idea.src/.idea/dataSources.xml | 13 ++ src/api.Tests/ProjectEndpointTests.cs | 197 ++++++++++++++++++ src/api.Tests/WorkspaceEndpointTests.cs | 185 ++++++++++++++++ .../Projects/Common/ProjectResponses.cs | 12 ++ .../Endpoints/CreateProjectEndpoint.cs | 72 +++++++ .../Endpoints/DeleteProjectEndpoint.cs | 42 ++++ .../Projects/Endpoints/GetProjectEndpoint.cs | 46 ++++ .../Endpoints/ListProjectsEndpoint.cs | 50 +++++ .../Endpoints/UpdateProjectEndpoint.cs | 62 ++++++ .../Workspaces/Common/WorkspaceResponses.cs | 12 ++ .../Endpoints/CreateWorkspaceEndpoint.cs | 58 ++++++ .../Endpoints/DeleteWorkspaceEndpoint.cs | 40 ++++ .../Endpoints/GetWorkspaceEndpoint.cs | 45 ++++ .../Endpoints/ListWorkspacesEndpoint.cs | 34 +++ .../Endpoints/UpdateWorkspaceEndpoint.cs | 60 ++++++ 15 files changed, 928 insertions(+) create mode 100644 src/.idea/.idea.src/.idea/dataSources.xml create mode 100644 src/api.Tests/ProjectEndpointTests.cs create mode 100644 src/api.Tests/WorkspaceEndpointTests.cs create mode 100644 src/api/Features/Projects/Common/ProjectResponses.cs create mode 100644 src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs create mode 100644 src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs create mode 100644 src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs create mode 100644 src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs create mode 100644 src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs create mode 100644 src/api/Features/Workspaces/Common/WorkspaceResponses.cs create mode 100644 src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs create mode 100644 src/api/Features/Workspaces/Endpoints/DeleteWorkspaceEndpoint.cs create mode 100644 src/api/Features/Workspaces/Endpoints/GetWorkspaceEndpoint.cs create mode 100644 src/api/Features/Workspaces/Endpoints/ListWorkspacesEndpoint.cs create mode 100644 src/api/Features/Workspaces/Endpoints/UpdateWorkspaceEndpoint.cs diff --git a/src/.idea/.idea.src/.idea/dataSources.xml b/src/.idea/.idea.src/.idea/dataSources.xml new file mode 100644 index 0000000..5a09dd3 --- /dev/null +++ b/src/.idea/.idea.src/.idea/dataSources.xml @@ -0,0 +1,13 @@ + + + + + postgresql + true + true + org.postgresql.Driver + jdbc:postgresql://localhost:5400/trakqr?password=P%40ssword123%21&user=sa + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/src/api.Tests/ProjectEndpointTests.cs b/src/api.Tests/ProjectEndpointTests.cs new file mode 100644 index 0000000..ba38463 --- /dev/null +++ b/src/api.Tests/ProjectEndpointTests.cs @@ -0,0 +1,197 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using api.Features.Auth.Common; +using api.Features.Projects.Common; +using api.Features.Workspaces.Common; +using FluentAssertions; + +namespace Api.Tests; + +public class ProjectEndpointTests(ApiWebApplicationFactory factory) + : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email) + { + var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); + if (response.StatusCode == HttpStatusCode.Conflict) + { + response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); + } + var authResult = await response.Content.ReadFromJsonAsync(); + var token = authResult!.Token; + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Get the default workspace + var wsResponse = await _client.GetAsync("/workspaces"); + var wsResult = await wsResponse.Content.ReadFromJsonAsync(); + var workspaceId = wsResult!.Workspaces.First().Id; + + return (token, workspaceId); + } + + [Fact] + public async Task ListProjects_WithValidWorkspace_ReturnsProjects() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-proj@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + } + + [Fact] + public async Task ListProjects_WithInvalidWorkspace_ReturnsNotFound() + { + // Arrange + var (token, _) = await SetupAuthAndWorkspaceAsync("list-proj-invalid@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync($"/workspaces/{Guid.NewGuid()}/projects"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateProject_WithValidData_ReturnsCreated() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-proj@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Name.Should().Be("Test Project"); + result.WorkspaceId.Should().Be(workspaceId); + } + + [Fact] + public async Task CreateProject_WithEmptyName_ReturnsBadRequest() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-proj-invalid@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetProject_WithValidId_ReturnsProject() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{created!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result!.Id.Should().Be(created.Id); + result.Name.Should().Be("Get Test"); + } + + [Fact] + public async Task GetProject_WithInvalidId_ReturnsNotFound() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj-invalid@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateProject_WithValidData_ReturnsUpdated() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}", new { Name = "Updated" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result!.Name.Should().Be("Updated"); + } + + [Fact] + public async Task DeleteProject_WithValidId_ReturnsSuccess() + { + // Arrange + var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/projects/{created!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify it's deleted + var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{created.Id}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Project_CannotAccessOtherUsersProject() + { + // Arrange - Create two users + var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("user1-proj@example.com"); + var (token2, _) = await SetupAuthAndWorkspaceAsync("user2-proj@example.com"); + + // Create project as user1 + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1); + var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Try to access as user2 + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2); + + // Act + var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}"); + var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}", new { Name = "Hacked" }); + var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}"); + + // Assert - All should return NotFound (not exposing existence) + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/src/api.Tests/WorkspaceEndpointTests.cs b/src/api.Tests/WorkspaceEndpointTests.cs new file mode 100644 index 0000000..2704b37 --- /dev/null +++ b/src/api.Tests/WorkspaceEndpointTests.cs @@ -0,0 +1,185 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using api.Features.Auth.Common; +using api.Features.Workspaces.Common; +using FluentAssertions; + +namespace Api.Tests; + +public class WorkspaceEndpointTests(ApiWebApplicationFactory factory) + : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private async Task GetAuthTokenAsync(string email = "workspace-test@example.com") + { + var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" }); + if (response.StatusCode == HttpStatusCode.Conflict) + { + response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" }); + } + var result = await response.Content.ReadFromJsonAsync(); + return result!.Token; + } + + [Fact] + public async Task ListWorkspaces_WithValidToken_ReturnsWorkspaces() + { + // Arrange + var token = await GetAuthTokenAsync("list-ws@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync("/workspaces"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Workspaces.Should().HaveCountGreaterThanOrEqualTo(1); // Default workspace created on registration + } + + [Fact] + public async Task ListWorkspaces_WithoutToken_ReturnsUnauthorized() + { + // Act + var response = await _client.GetAsync("/workspaces"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task CreateWorkspace_WithValidData_ReturnsCreated() + { + // Arrange + var token = await GetAuthTokenAsync("create-ws@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.PostAsJsonAsync("/workspaces", new { Name = "Test Workspace" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Name.Should().Be("Test Workspace"); + result.Plan.Should().Be("Free"); + result.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task CreateWorkspace_WithEmptyName_ReturnsBadRequest() + { + // Arrange + var token = await GetAuthTokenAsync("create-ws-invalid@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.PostAsJsonAsync("/workspaces", new { Name = "" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetWorkspace_WithValidId_ReturnsWorkspace() + { + // Arrange + var token = await GetAuthTokenAsync("get-ws@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Get Test" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.GetAsync($"/workspaces/{created!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result!.Id.Should().Be(created.Id); + result.Name.Should().Be("Get Test"); + } + + [Fact] + public async Task GetWorkspace_WithInvalidId_ReturnsNotFound() + { + // Arrange + var token = await GetAuthTokenAsync("get-ws-invalid@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await _client.GetAsync($"/workspaces/{Guid.NewGuid()}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateWorkspace_WithValidData_ReturnsUpdated() + { + // Arrange + var token = await GetAuthTokenAsync("update-ws@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "Original Name" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.PutAsJsonAsync($"/workspaces/{created!.Id}", new { Name = "Updated Name" }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result!.Name.Should().Be("Updated Name"); + } + + [Fact] + public async Task DeleteWorkspace_WithValidId_ReturnsSuccess() + { + // Arrange + var token = await GetAuthTokenAsync("delete-ws@example.com"); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Act + var response = await _client.DeleteAsync($"/workspaces/{created!.Id}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify it's deleted + var getResponse = await _client.GetAsync($"/workspaces/{created.Id}"); + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Workspace_CannotAccessOtherUsersWorkspace() + { + // Arrange - Create two users + var token1 = await GetAuthTokenAsync("user1-ws@example.com"); + var token2 = await GetAuthTokenAsync("user2-ws@example.com"); + + // Create workspace as user1 + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1); + var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "User1 Workspace" }); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Try to access as user2 + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2); + + // Act + var getResponse = await _client.GetAsync($"/workspaces/{created!.Id}"); + var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{created.Id}", new { Name = "Hacked" }); + var deleteResponse = await _client.DeleteAsync($"/workspaces/{created.Id}"); + + // Assert - All should return NotFound (not exposing existence) + getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/src/api/Features/Projects/Common/ProjectResponses.cs b/src/api/Features/Projects/Common/ProjectResponses.cs new file mode 100644 index 0000000..93985e5 --- /dev/null +++ b/src/api/Features/Projects/Common/ProjectResponses.cs @@ -0,0 +1,12 @@ +namespace api.Features.Projects.Common; + +public record ProjectResponse( + Guid Id, + Guid WorkspaceId, + string Name, + DateTime CreatedAt +); + +public record ProjectListResponse( + IEnumerable Projects +); diff --git a/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs new file mode 100644 index 0000000..4a0b422 --- /dev/null +++ b/src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs @@ -0,0 +1,72 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Projects.Common; +using api.Models; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Projects.Endpoints; + +public class CreateProjectRequest +{ + public Guid WorkspaceId { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class CreateProjectValidator : Validator +{ + public CreateProjectValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters"); + } +} + +public class CreateProjectEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/workspaces/{WorkspaceId}/projects"); + } + + public override async Task HandleAsync(CreateProjectRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspaceExists = await db.Workspaces + .AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (!workspaceExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + var project = new Project + { + Id = Guid.NewGuid(), + WorkspaceId = req.WorkspaceId, + Name = req.Name, + CreatedAt = DateTime.UtcNow + }; + + db.Projects.Add(project); + await db.SaveChangesAsync(ct); + + var response = new ProjectResponse( + project.Id, + project.WorkspaceId, + project.Name, + project.CreatedAt + ); + + await Send.CreatedAtAsync(response, cancellation: ct); + + await HttpContext.Response.SendAsync(response, 201, cancellation: ct); + } +} diff --git a/src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs new file mode 100644 index 0000000..e876e70 --- /dev/null +++ b/src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Projects.Endpoints; + +public class DeleteProjectRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } +} + +public class DeleteProjectEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Delete("/workspaces/{WorkspaceId}/projects/{Id}"); + } + + public override async Task HandleAsync(DeleteProjectRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var project = await db.Projects + .Include(p => p.Workspace) + .FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct); + + if (project is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct); + return; + } + + db.Projects.Remove(project); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Project deleted"), 200, cancellation: ct); + } +} diff --git a/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs new file mode 100644 index 0000000..c238a98 --- /dev/null +++ b/src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs @@ -0,0 +1,46 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Projects.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Projects.Endpoints; + +public class GetProjectRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } +} + +public class GetProjectEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{WorkspaceId}/projects/{Id}"); + } + + public override async Task HandleAsync(GetProjectRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var project = await db.Projects + .Where(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId) + .Select(p => new ProjectResponse( + p.Id, + p.WorkspaceId, + p.Name, + p.CreatedAt + )) + .FirstOrDefaultAsync(ct); + + if (project is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct); + return; + } + + await HttpContext.Response.SendAsync(project, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs b/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs new file mode 100644 index 0000000..70f1ba9 --- /dev/null +++ b/src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs @@ -0,0 +1,50 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Projects.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Projects.Endpoints; + +public class ListProjectsRequest +{ + public Guid WorkspaceId { get; set; } +} + +public class ListProjectsEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{WorkspaceId}/projects"); + } + + public override async Task HandleAsync(ListProjectsRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + // Verify workspace ownership + var workspaceExists = await db.Workspaces + .AnyAsync(w => w.Id == req.WorkspaceId && w.OwnerUserId == userId, ct); + + if (!workspaceExists) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + var projects = await db.Projects + .Where(p => p.WorkspaceId == req.WorkspaceId) + .OrderByDescending(p => p.CreatedAt) + .Select(p => new ProjectResponse( + p.Id, + p.WorkspaceId, + p.Name, + p.CreatedAt + )) + .ToListAsync(ct); + + await HttpContext.Response.SendAsync(new ProjectListResponse(projects), 200, cancellation: ct); + } +} diff --git a/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs b/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs new file mode 100644 index 0000000..7a1a09b --- /dev/null +++ b/src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Projects.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Projects.Endpoints; + +public class UpdateProjectRequest +{ + public Guid WorkspaceId { get; set; } + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class UpdateProjectValidator : Validator +{ + public UpdateProjectValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters"); + } +} + +public class UpdateProjectEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Put("/workspaces/{WorkspaceId}/projects/{Id}"); + } + + public override async Task HandleAsync(UpdateProjectRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var project = await db.Projects + .Include(p => p.Workspace) + .FirstOrDefaultAsync(p => p.Id == req.Id && p.WorkspaceId == req.WorkspaceId && p.Workspace.OwnerUserId == userId, ct); + + if (project is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Project not found"), 404, cancellation: ct); + return; + } + + project.Name = req.Name; + await db.SaveChangesAsync(ct); + + var response = new ProjectResponse( + project.Id, + project.WorkspaceId, + project.Name, + project.CreatedAt + ); + + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Workspaces/Common/WorkspaceResponses.cs b/src/api/Features/Workspaces/Common/WorkspaceResponses.cs new file mode 100644 index 0000000..d6eb4b2 --- /dev/null +++ b/src/api/Features/Workspaces/Common/WorkspaceResponses.cs @@ -0,0 +1,12 @@ +namespace api.Features.Workspaces.Common; + +public record WorkspaceResponse( + Guid Id, + string Name, + string Plan, + DateTime CreatedAt +); + +public record WorkspaceListResponse( + IEnumerable Workspaces +); diff --git a/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs b/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs new file mode 100644 index 0000000..d25e4a6 --- /dev/null +++ b/src/api/Features/Workspaces/Endpoints/CreateWorkspaceEndpoint.cs @@ -0,0 +1,58 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Workspaces.Common; +using api.Models; +using FastEndpoints; +using FluentValidation; + +namespace api.Features.Workspaces.Endpoints; + +public class CreateWorkspaceRequest +{ + public string Name { get; set; } = string.Empty; +} + +public class CreateWorkspaceValidator : Validator +{ + public CreateWorkspaceValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters"); + } +} + +public class CreateWorkspaceEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Post("/workspaces"); + } + + public override async Task HandleAsync(CreateWorkspaceRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspace = new Workspace + { + Id = Guid.NewGuid(), + OwnerUserId = userId, + Name = req.Name, + Plan = WorkspacePlan.Free, + CreatedAt = DateTime.UtcNow + }; + + db.Workspaces.Add(workspace); + await db.SaveChangesAsync(ct); + + var response = new WorkspaceResponse( + workspace.Id, + workspace.Name, + workspace.Plan.ToString(), + workspace.CreatedAt + ); + + await HttpContext.Response.SendAsync(response, 201, cancellation: ct); + } +} diff --git a/src/api/Features/Workspaces/Endpoints/DeleteWorkspaceEndpoint.cs b/src/api/Features/Workspaces/Endpoints/DeleteWorkspaceEndpoint.cs new file mode 100644 index 0000000..733bb0c --- /dev/null +++ b/src/api/Features/Workspaces/Endpoints/DeleteWorkspaceEndpoint.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Workspaces.Endpoints; + +public class DeleteWorkspaceRequest +{ + public Guid Id { get; set; } +} + +public class DeleteWorkspaceEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Delete("/workspaces/{Id}"); + } + + public override async Task HandleAsync(DeleteWorkspaceRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.Id && w.OwnerUserId == userId, ct); + + if (workspace is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + db.Workspaces.Remove(workspace); + await db.SaveChangesAsync(ct); + + await HttpContext.Response.SendAsync(new MessageResponse("Workspace deleted"), 200, cancellation: ct); + } +} diff --git a/src/api/Features/Workspaces/Endpoints/GetWorkspaceEndpoint.cs b/src/api/Features/Workspaces/Endpoints/GetWorkspaceEndpoint.cs new file mode 100644 index 0000000..578e0aa --- /dev/null +++ b/src/api/Features/Workspaces/Endpoints/GetWorkspaceEndpoint.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Workspaces.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Workspaces.Endpoints; + +public class GetWorkspaceRequest +{ + public Guid Id { get; set; } +} + +public class GetWorkspaceEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Get("/workspaces/{Id}"); + } + + public override async Task HandleAsync(GetWorkspaceRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspace = await db.Workspaces + .Where(w => w.Id == req.Id && w.OwnerUserId == userId) + .Select(w => new WorkspaceResponse( + w.Id, + w.Name, + w.Plan.ToString(), + w.CreatedAt + )) + .FirstOrDefaultAsync(ct); + + if (workspace is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + await HttpContext.Response.SendAsync(workspace, 200, cancellation: ct); + } +} diff --git a/src/api/Features/Workspaces/Endpoints/ListWorkspacesEndpoint.cs b/src/api/Features/Workspaces/Endpoints/ListWorkspacesEndpoint.cs new file mode 100644 index 0000000..9e2dcae --- /dev/null +++ b/src/api/Features/Workspaces/Endpoints/ListWorkspacesEndpoint.cs @@ -0,0 +1,34 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Workspaces.Common; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Workspaces.Endpoints; + +public class ListWorkspacesEndpoint(AppDbContext db) + : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/workspaces"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspaces = await db.Workspaces + .Where(w => w.OwnerUserId == userId) + .OrderByDescending(w => w.CreatedAt) + .Select(w => new WorkspaceResponse( + w.Id, + w.Name, + w.Plan.ToString(), + w.CreatedAt + )) + .ToListAsync(ct); + + await HttpContext.Response.SendAsync(new WorkspaceListResponse(workspaces), 200, cancellation: ct); + } +} diff --git a/src/api/Features/Workspaces/Endpoints/UpdateWorkspaceEndpoint.cs b/src/api/Features/Workspaces/Endpoints/UpdateWorkspaceEndpoint.cs new file mode 100644 index 0000000..7c99c32 --- /dev/null +++ b/src/api/Features/Workspaces/Endpoints/UpdateWorkspaceEndpoint.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using api.Data; +using api.Features.Auth.Common; +using api.Features.Workspaces.Common; +using FastEndpoints; +using FluentValidation; +using Microsoft.EntityFrameworkCore; + +namespace api.Features.Workspaces.Endpoints; + +public class UpdateWorkspaceRequest +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class UpdateWorkspaceValidator : Validator +{ + public UpdateWorkspaceValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required") + .MaximumLength(100).WithMessage("Name must not exceed 100 characters"); + } +} + +public class UpdateWorkspaceEndpoint(AppDbContext db) + : Endpoint +{ + public override void Configure() + { + Put("/workspaces/{Id}"); + } + + public override async Task HandleAsync(UpdateWorkspaceRequest req, CancellationToken ct) + { + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + var workspace = await db.Workspaces + .FirstOrDefaultAsync(w => w.Id == req.Id && w.OwnerUserId == userId, ct); + + if (workspace is null) + { + await HttpContext.Response.SendAsync(new MessageResponse("Workspace not found"), 404, cancellation: ct); + return; + } + + workspace.Name = req.Name; + await db.SaveChangesAsync(ct); + + var response = new WorkspaceResponse( + workspace.Id, + workspace.Name, + workspace.Plan.ToString(), + workspace.CreatedAt + ); + + await HttpContext.Response.SendAsync(response, 200, cancellation: ct); + } +}