feat(workspace): adds workspace and projects

This commit is contained in:
2026-01-28 12:58:28 -05:00
parent 11d6390884
commit c23156c6b4
15 changed files with 928 additions and 0 deletions

View 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&amp;user=sa</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

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

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

View 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
);

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

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

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

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

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

View 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
);

View File

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

View File

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

View File

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

View File

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

View File

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