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);
+ }
+}