Repository: ardalis/CleanArchitecture

Issue:
Please show how you would create new ToDoItems
<!-- ⚠️⚠️ Do Not Delete This! feature_request_template ⚠️⚠️ -->
<!-- Please search existing issues to avoid creating duplicates. -->

<!-- Describe the feature you'd like. -->

In this template, you aggregate ToDoItems within the ProjectAggregate.  However, you also stop short of showing how you would, via API, author new ToDoItem instances.  I think it would be beneficial to show how you would create these new child items of a project.  

I think it would be wonderful to see:
- How you would define this in an ApiEndpoint.
- How you would incorporate this within the ProjectAggregate.
- How you would codify a Many-to-Many relationship as opposed to the One-To-Many relationship between Project and ToDoItem.
  - Is this implied by the Project <-> Contributor relationship?
  - What if the Contributor to a Project had a title within that Project? Senior Contributor vs Junior Contributor?  If that were the case what ApiEndpoint would the management of that be within your example domain?

Thanks for such a great template!


Hints:

Patch:
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 402b2d6ab..a4e72e15f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
     <PackageVersion Include="FastEndpoints.ApiExplorer" Version="2.0.1" />
     <PackageVersion Include="FastEndpoints.Swagger" Version="5.5.0" />
     <PackageVersion Include="FastEndpoints.Swagger.Swashbuckle" Version="2.0.1" />
+    <PackageVersion Include="FluentAssertions" Version="6.10.0" />
     <PackageVersion Include="MediatR" Version="12.0.1" />
     <PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.4" />
     <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.4" />
diff --git a/src/Clean.Architecture.Core/ProjectAggregate/Specifications/ContributorByIdSpec.cs b/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs
similarity index 68%
rename from src/Clean.Architecture.Core/ProjectAggregate/Specifications/ContributorByIdSpec.cs
rename to src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs
index e7e561691..2529afe63 100644
--- a/src/Clean.Architecture.Core/ProjectAggregate/Specifications/ContributorByIdSpec.cs
+++ b/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs
@@ -1,7 +1,6 @@
 ﻿using Ardalis.Specification;
-using Clean.Architecture.Core.ContributorAggregate;
 
-namespace Clean.Architecture.Core.ProjectAggregate.Specifications;
+namespace Clean.Architecture.Core.ContributorAggregate.Specifications;
 
 public class ContributorByIdSpec : Specification<Contributor>, ISingleResultSpecification
 {
diff --git a/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs b/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs
index 7f9434d87..54fe303ab 100644
--- a/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs
+++ b/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs
@@ -1,8 +1,10 @@
-using Ardalis.Result;
+﻿using Ardalis.Result;
 
 namespace Clean.Architecture.Core.Interfaces;
 
 public interface IDeleteContributorService
 {
-    public Task<Result> DeleteContributor(int contributorId);
+  // This service and method exist to provide a place in which to fire domain events
+  // when deleting this aggregate root entity
+  public Task<Result> DeleteContributor(int contributorId);
 }
diff --git a/src/Clean.Architecture.Web/Endpoints/ContributorEndpoints/GetById.cs b/src/Clean.Architecture.Web/Endpoints/ContributorEndpoints/GetById.cs
index 8b35be410..042c99fa4 100644
--- a/src/Clean.Architecture.Web/Endpoints/ContributorEndpoints/GetById.cs
+++ b/src/Clean.Architecture.Web/Endpoints/ContributorEndpoints/GetById.cs
@@ -1,5 +1,5 @@
 ﻿using Clean.Architecture.Core.ContributorAggregate;
-using Clean.Architecture.Core.ProjectAggregate.Specifications;
+using Clean.Architecture.Core.ContributorAggregate.Specifications;
 using Clean.Architecture.SharedKernel.Interfaces;
 using FastEndpoints;
 
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.CreateToDoItemRequest.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.CreateToDoItemRequest.cs
new file mode 100644
index 000000000..e0b11f085
--- /dev/null
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.CreateToDoItemRequest.cs
@@ -0,0 +1,19 @@
+﻿using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Clean.Architecture.Web.Endpoints.ProjectEndpoints;
+
+public class CreateToDoItemRequest
+{
+  public const string Route = "/Projects/{ProjectId:int}/ToDoItems";
+  public static string BuildRoute(int projectId) => Route.Replace("{ProjectId:int}", projectId.ToString());
+
+  [Required]
+  [FromRoute]
+  public int ProjectId { get; set; }
+
+  [Required]
+  public string? Title { get; set; }
+  public string? Description { get; set; }
+  public int? ContributorId { get; set; }
+}
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.cs
new file mode 100644
index 000000000..55c2c9c50
--- /dev/null
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/CreateToDoItem.cs
@@ -0,0 +1,54 @@
+﻿using Ardalis.ApiEndpoints;
+using Clean.Architecture.Core.ProjectAggregate;
+using Clean.Architecture.Core.ProjectAggregate.Specifications;
+using Clean.Architecture.SharedKernel.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace Clean.Architecture.Web.Endpoints.ProjectEndpoints;
+
+public class CreateToDoItem : EndpointBaseAsync
+  .WithRequest<CreateToDoItemRequest>
+  .WithActionResult
+{
+  private readonly IRepository<Project> _repository;
+
+  public CreateToDoItem(IRepository<Project> repository)
+  {
+    _repository = repository;
+  }
+
+  [HttpPost(CreateToDoItemRequest.Route)]
+  [SwaggerOperation(
+    Summary = "Creates a new ToDo Item for a Project",
+    Description = "Creates a new ToDo Item for a Project",
+    OperationId = "Project.CreateToDoItem",
+    Tags = new[] { "ProjectEndpoints" })
+  ]
+  public override async Task<ActionResult> HandleAsync(
+    CreateToDoItemRequest request,
+    CancellationToken cancellationToken = new())
+  {
+    var spec = new ProjectByIdWithItemsSpec(request.ProjectId);
+    var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken);
+    if (entity == null)
+    {
+      return NotFound();
+    }
+
+    var newItem = new ToDoItem()
+    {
+      Title = request.Title!,
+      Description = request.Description!
+    };
+
+    if(request.ContributorId.HasValue) 
+    { 
+      newItem.AddContributor(request.ContributorId.Value);
+    }
+    entity.AddItem(newItem);
+    await _repository.UpdateAsync(entity);
+
+    return Created(GetProjectByIdRequest.BuildRoute(request.ProjectId), null);
+  }
+}
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/GetById.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/GetById.cs
index e681c1308..8a396d72b 100644
--- a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/GetById.cs
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/GetById.cs
@@ -40,7 +40,12 @@ public override async Task<ActionResult<GetProjectByIdResponse>> HandleAsync(
     (
       id: entity.Id,
       name: entity.Name,
-      items: entity.Items.Select(item => new ToDoItemRecord(item.Id, item.Title, item.Description, item.IsDone))
+      items: entity.Items.Select(
+        item => new ToDoItemRecord(item.Id, 
+          item.Title,
+          item.Description,
+          item.IsDone,
+          item.ContributorId))
         .ToList()
     );
 
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ListIncomplete.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ListIncomplete.cs
index f061b203e..fdcefe195 100644
--- a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ListIncomplete.cs
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ListIncomplete.cs
@@ -43,7 +43,8 @@ public override async Task<ActionResult<ListIncompleteResponse>> HandleAsync(
           item => new ToDoItemRecord(item.Id,
             item.Title,
             item.Description,
-            item.IsDone)));
+            item.IsDone,
+            item.ContributorId)));
     }
     else if (result.Status == Ardalis.Result.ResultStatus.Invalid)
     {
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ToDoItemRecord.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ToDoItemRecord.cs
index 694a76697..f351ebea9 100644
--- a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ToDoItemRecord.cs
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/ToDoItemRecord.cs
@@ -1,3 +1,3 @@
 ﻿namespace Clean.Architecture.Web.Endpoints.ProjectEndpoints;
 
-public record ToDoItemRecord(int Id, string Title, string Description, bool IsDone);
+public record ToDoItemRecord(int Id, string Title, string Description, bool IsDone, int? ContributorId);
diff --git a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Update.cs b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Update.cs
index 9deab11de..f063dfd47 100644
--- a/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Update.cs
+++ b/src/Clean.Architecture.Web/Endpoints/ProjectEndpoints/Update.cs
@@ -20,7 +20,7 @@ public Update(IRepository<Project> repository)
   [HttpPut(UpdateProjectRequest.Route)]
   [SwaggerOperation(
       Summary = "Updates a Project",
-      Description = "Updates a Project with a longer description",
+      Description = "Updates a Project. Only supports changing the name.",
       OperationId = "Projects.Update",
       Tags = new[] { "ProjectEndpoints" })
   ]


Test patch:
diff --git a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectCreate.cs b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectCreate.cs
new file mode 100644
index 000000000..e122bbfac
--- /dev/null
+++ b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectCreate.cs
@@ -0,0 +1,68 @@
+﻿using Ardalis.HttpClientTestExtensions;
+using Clean.Architecture.Web.Endpoints.ProjectEndpoints;
+using Xunit;
+using FluentAssertions;
+using Clean.Architecture.Web;
+
+namespace Clean.Architecture.FunctionalTests.ApiEndpoints;
+
+[Collection("Sequential")]
+public class ProjectCreate : IClassFixture<CustomWebApplicationFactory<Program>>
+{
+  private readonly HttpClient _client;
+
+  public ProjectCreate(CustomWebApplicationFactory<Program> factory)
+  {
+    _client = factory.CreateClient();
+  }
+
+  [Fact]
+  public async Task ReturnsOneProject()
+  {
+    string testName = Guid.NewGuid().ToString();
+    var request = new CreateProjectRequest() { Name = testName };
+    var content = StringContentHelpers.FromModelAsJson(request);
+
+    var result = await _client.PostAndDeserializeAsync<CreateProjectResponse>(
+      CreateProjectRequest.Route, content);
+
+    result.Name.Should().Be(testName);
+    result.Id.Should().BeGreaterThan(0);
+  }
+}
+
+[Collection("Sequential")]
+public class ProjectAddToDoItem : IClassFixture<CustomWebApplicationFactory<Program>>
+{
+  private readonly HttpClient _client;
+
+  public ProjectAddToDoItem(CustomWebApplicationFactory<Program> factory)
+  {
+    _client = factory.CreateClient();
+  }
+
+  [Fact]
+  public async Task AddsItemAndReturnsRouteToProject()
+  {
+    string toDoTitle = Guid.NewGuid().ToString();
+    int testProjectId = SeedData.TestProject1.Id;
+    var request = new CreateToDoItemRequest() { 
+      Title = toDoTitle, 
+      ProjectId = testProjectId,
+      Description = toDoTitle
+    };
+    var content = StringContentHelpers.FromModelAsJson(request);
+
+    var result = await _client.PostAsync(CreateToDoItemRequest.BuildRoute(testProjectId), content);
+    
+    // useful for debugging error responses:
+    // var stringContent = await result.Content.ReadAsStringAsync();
+
+    string expectedRoute = GetProjectByIdRequest.BuildRoute(testProjectId);
+    result.Headers.Location!.ToString().Should().Be(expectedRoute);
+
+    var updatedProject = await _client.GetAndDeserializeAsync<GetProjectByIdResponse>(expectedRoute);
+
+    updatedProject.Items.Should().ContainSingle(item => item.Title == toDoTitle);
+  }
+}
diff --git a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectList.cs b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectList.cs
index 5064edb3e..9c1ea80d1 100644
--- a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectList.cs
+++ b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ProjectList.cs
@@ -1,4 +1,5 @@
 ﻿using Ardalis.HttpClientTestExtensions;
+using Clean.Architecture.Core.ProjectAggregate;
 using Clean.Architecture.Web;
 using Clean.Architecture.Web.Endpoints.ProjectEndpoints;
 using Xunit;
diff --git a/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj b/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj
index 5f25a3c7e..655190bf3 100644
--- a/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj
+++ b/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj
@@ -8,6 +8,7 @@
   </PropertyGroup>
   
   <ItemGroup>
+    <PackageReference Include="FluentAssertions" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" />
     <PackageReference Include="xunit" />
     <PackageReference Include="xunit.runner.visualstudio">