feat(workspace): adds workspace and projects
This commit is contained in:
13
src/.idea/.idea.src/.idea/dataSources.xml
generated
Normal file
13
src/.idea/.idea.src/.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="trakqr@localhost" uuid="31270fe6-401c-43ab-90e6-34e13ddaf5e8">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<configured-by-url>true</configured-by-url>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5400/trakqr?password=P%40ssword123%21&user=sa</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
197
src/api.Tests/ProjectEndpointTests.cs
Normal file
197
src/api.Tests/ProjectEndpointTests.cs
Normal file
@@ -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<ApiWebApplicationFactory>
|
||||
{
|
||||
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<AuthResponse>();
|
||||
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<WorkspaceListResponse>();
|
||||
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<ProjectListResponse>();
|
||||
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<ProjectResponse>();
|
||||
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<ProjectResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{created!.Id}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<ProjectResponse>();
|
||||
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<ProjectResponse>();
|
||||
|
||||
// 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<ProjectResponse>();
|
||||
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<ProjectResponse>();
|
||||
|
||||
// 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<ProjectResponse>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
185
src/api.Tests/WorkspaceEndpointTests.cs
Normal file
185
src/api.Tests/WorkspaceEndpointTests.cs
Normal file
@@ -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<ApiWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private async Task<string> 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<AuthResponse>();
|
||||
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<WorkspaceListResponse>();
|
||||
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<WorkspaceResponse>();
|
||||
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<WorkspaceResponse>();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/workspaces/{created!.Id}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
|
||||
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<WorkspaceResponse>();
|
||||
|
||||
// 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<WorkspaceResponse>();
|
||||
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<WorkspaceResponse>();
|
||||
|
||||
// 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<WorkspaceResponse>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
12
src/api/Features/Projects/Common/ProjectResponses.cs
Normal file
12
src/api/Features/Projects/Common/ProjectResponses.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace api.Features.Projects.Common;
|
||||
|
||||
public record ProjectResponse(
|
||||
Guid Id,
|
||||
Guid WorkspaceId,
|
||||
string Name,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record ProjectListResponse(
|
||||
IEnumerable<ProjectResponse> Projects
|
||||
);
|
||||
72
src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs
Normal file
72
src/api/Features/Projects/Endpoints/CreateProjectEndpoint.cs
Normal file
@@ -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<CreateProjectRequest>
|
||||
{
|
||||
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<CreateProjectRequest, ProjectResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
42
src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs
Normal file
42
src/api/Features/Projects/Endpoints/DeleteProjectEndpoint.cs
Normal file
@@ -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<DeleteProjectRequest>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
46
src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs
Normal file
46
src/api/Features/Projects/Endpoints/GetProjectEndpoint.cs
Normal file
@@ -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<GetProjectRequest, ProjectResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
50
src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs
Normal file
50
src/api/Features/Projects/Endpoints/ListProjectsEndpoint.cs
Normal file
@@ -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<ListProjectsRequest, ProjectListResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
62
src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs
Normal file
62
src/api/Features/Projects/Endpoints/UpdateProjectEndpoint.cs
Normal file
@@ -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<UpdateProjectRequest>
|
||||
{
|
||||
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<UpdateProjectRequest, ProjectResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
12
src/api/Features/Workspaces/Common/WorkspaceResponses.cs
Normal file
12
src/api/Features/Workspaces/Common/WorkspaceResponses.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace api.Features.Workspaces.Common;
|
||||
|
||||
public record WorkspaceResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Plan,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
public record WorkspaceListResponse(
|
||||
IEnumerable<WorkspaceResponse> Workspaces
|
||||
);
|
||||
@@ -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<CreateWorkspaceRequest>
|
||||
{
|
||||
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<CreateWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<DeleteWorkspaceRequest>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<GetWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceListResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateWorkspaceRequest>
|
||||
{
|
||||
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<UpdateWorkspaceRequest, WorkspaceResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user