Skip to content

Commit 50b0616

Browse files
committed
feat: Add Favorite column to Project entity
1 parent 6053868 commit 50b0616

15 files changed

Lines changed: 3918 additions & 12 deletions

File tree

PrismaDotnetApi/PrismaApi.Api/Controllers/ProjectsController.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ public async Task<ActionResult<List<ProjectOutgoingDto>>> UpdateProjects([FromBo
7979
}
8080
}
8181

82+
[HttpPatch("projects/{id:guid}/favorite")]
83+
public async Task<ActionResult<ProjectOutgoingDto>> UpdateFavorite(
84+
Guid id,
85+
[FromBody] ProjectFavoriteIncomingDto dto,
86+
CancellationToken ct = default)
87+
{
88+
UserOutgoingDto user = HttpContext.GetLoadedUser();
89+
await BeginTransactionAsync(ct);
90+
try
91+
{
92+
var result = await _projectService.UpdateFavoriteAsync(id, dto.Favorite, user, ct);
93+
await CommitTransactionAsync(ct);
94+
return result is null ? NotFound() : Ok(result);
95+
}
96+
catch
97+
{
98+
await RollbackTransactionAsync(CancellationToken.None);
99+
throw;
100+
}
101+
}
102+
82103
[HttpDelete("projects/{id:guid}")]
83104
public async Task<IActionResult> DeleteProject(Guid id, CancellationToken ct = default)
84105
{

PrismaDotnetApi/PrismaApi.Application/Interfaces/Repositories/IProjectRoleRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ namespace PrismaApi.Application.Interfaces.Repositories;
66

77
public interface IProjectRoleRepository : ICrudRepository<ProjectRole, Guid>
88
{
9+
Task<bool> UpdateFavoriteAsync(Guid projectId, string userId, bool favorite, CancellationToken ct = default);
910
Task UpdateRangeAsync(IEnumerable<ProjectRole> incomingEntities, Expression<Func<ProjectRole, bool>> filterPredicate, CancellationToken ct = default);
1011
Task<bool> IsUserFacilitatorFromProjectIdsAsync(List<Guid> projectIds, UserOutgoingDto userDto, CancellationToken ct = default);
1112
Task<bool> IsUserFacilitatorFromRoleIdsAsync(List<Guid> roleIds, UserOutgoingDto userDto, CancellationToken ct = default);

PrismaDotnetApi/PrismaApi.Application/Interfaces/Services/IProjectService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ public interface IProjectService
66
{
77
Task<List<ProjectOutgoingDto>> CreateAsync(List<ProjectCreateDto> dtos, bool createDefaultRole, UserOutgoingDto userDto, CancellationToken ct = default);
88
Task<List<ProjectOutgoingDto>> UpdateAsync(List<ProjectIncomingDto> dtos, UserOutgoingDto userDto, CancellationToken ct = default);
9+
Task<ProjectOutgoingDto?> UpdateFavoriteAsync(Guid id, bool favorite, UserOutgoingDto user, CancellationToken ct = default);
910
Task DeleteAsync(List<Guid> ids, UserOutgoingDto user, CancellationToken ct = default);
1011
Task<List<ProjectOutgoingDto>> GetAsync(List<Guid> ids, UserOutgoingDto user, CancellationToken ct = default);
1112
Task<List<ProjectOutgoingDto>> GetAllAsync(UserOutgoingDto user, CancellationToken ct = default);

PrismaDotnetApi/PrismaApi.Application/Mapping/ProjectMappingExtensions.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace PrismaApi.Application.Mapping;
66

77
public static class ProjectMappingExtensions
88
{
9-
public static ProjectOutgoingDto ToOutgoingDto(this Project entity)
9+
public static ProjectOutgoingDto ToOutgoingDto(this Project entity, string userId)
1010
{
1111
return new ProjectOutgoingDto
1212
{
@@ -16,13 +16,14 @@ public static ProjectOutgoingDto ToOutgoingDto(this Project entity)
1616
ParentProjectName = entity.ParentProjectName ?? "",
1717
OpportunityStatement = entity.OpportunityStatement,
1818
Public = entity.Public,
19+
Favorite = entity.ProjectRoles.FirstOrDefault(role => role.UserId == userId)?.Favorite ?? false,
1920
EndDate = entity.EndDate,
2021
Users = entity.ProjectRoles.ToOutgoingDtos(),
2122
BoardNodes = entity.BoardNodes.ToOutgoingDtos(),
2223
};
2324
}
2425

25-
public static PopulatedProjectDto ToPopulatedDto(this Project entity)
26+
public static PopulatedProjectDto ToPopulatedDto(this Project entity, string userId)
2627
{
2728
return new PopulatedProjectDto
2829
{
@@ -34,21 +35,22 @@ public static PopulatedProjectDto ToPopulatedDto(this Project entity)
3435
ParentProjectName = entity.ParentProjectName ?? "",
3536
OpportunityStatement = entity.OpportunityStatement,
3637
Public = entity.Public,
38+
Favorite = entity.ProjectRoles.FirstOrDefault(role => role.UserId == userId)?.Favorite ?? false,
3739
EndDate = entity.EndDate,
3840
Users = entity.ProjectRoles.ToOutgoingDtos(),
3941
BoardNodes = entity.BoardNodes.ToOutgoingDtos(),
4042

4143
};
4244
}
4345

44-
public static List<ProjectOutgoingDto> ToOutgoingDtos(this IEnumerable<Project> entities)
46+
public static List<ProjectOutgoingDto> ToOutgoingDtos(this IEnumerable<Project> entities, string userId)
4547
{
46-
return entities.Select(ToOutgoingDto).ToList();
48+
return entities.Select(entity => entity.ToOutgoingDto(userId)).ToList();
4749
}
4850

49-
public static List<PopulatedProjectDto> ToPopulatedDtos(this IEnumerable<Project> entities)
51+
public static List<PopulatedProjectDto> ToPopulatedDtos(this IEnumerable<Project> entities, string userId)
5052
{
51-
return entities.Select(ToPopulatedDto).ToList();
53+
return entities.Select(entity => entity.ToPopulatedDto(userId)).ToList();
5254
}
5355

5456
public static FullProjectForDuplicationDto ToFullProjectForDuplicationDto(this Project entity)
@@ -118,4 +120,5 @@ public static List<Project> ToEntities(this IEnumerable<ProjectIncomingDto> dtos
118120
{
119121
return dtos.Select(dto => dto.ToEntity(userDto)).ToList();
120122
}
123+
121124
}

PrismaDotnetApi/PrismaApi.Application/Repositories/ProjectRoleRepository.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ public ProjectRoleRepository(AppDbContext dbContext) : base(dbContext)
1414
{
1515
}
1616

17+
public async Task<bool> UpdateFavoriteAsync(Guid projectId, string userId, bool favorite, CancellationToken ct = default)
18+
{
19+
var projectRole = await DbContext.ProjectRoles
20+
.SingleOrDefaultAsync(role => role.ProjectId == projectId && role.UserId == userId, ct);
21+
if (projectRole is null)
22+
return false;
23+
24+
projectRole.Favorite = favorite;
25+
projectRole.UpdatedById = userId;
26+
await DbContext.SaveChangesAsync(ct);
27+
return true;
28+
}
29+
1730
public async Task UpdateRangeAsync(IEnumerable<ProjectRole> incomingEntities, Expression<Func<ProjectRole, bool>> filterPredicate, CancellationToken ct = default)
1831
{
1932
var incomingList = incomingEntities.ToList();

PrismaDotnetApi/PrismaApi.Application/Services/ProjectService.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public async Task<List<ProjectOutgoingDto>> CreateAsync(List<ProjectCreateDto> d
5858

5959
var ids = projectEntities.Select(p => p.Id).ToList();
6060
var projects = await _projectRepository.GetByIdsAsync(ids, withTracking: false, ct: ct);
61-
return projects.ToOutgoingDtos();
61+
return projects.ToOutgoingDtos(userDto.Id);
6262
}
6363

6464
public async Task<List<ProjectOutgoingDto>> UpdateAsync(List<ProjectIncomingDto> dtos, UserOutgoingDto userDto, CancellationToken ct = default)
@@ -72,7 +72,17 @@ public async Task<List<ProjectOutgoingDto>> UpdateAsync(List<ProjectIncomingDto>
7272
var projectEntities = dtos.ToEntities(userDto);
7373
var projects = await _projectRepository.UpdateRangeAsync(projectEntities, userDto, filterPredicate: UserFilter(userDto), ct);
7474
await EnsureDefaultSheetsExists([.. projectEntities.Select(p => p.Id)], userDto, ct);
75-
return projects.ToOutgoingDtos();
75+
return projects.ToOutgoingDtos(userDto.Id);
76+
}
77+
78+
public async Task<ProjectOutgoingDto?> UpdateFavoriteAsync(Guid id, bool favorite, UserOutgoingDto user, CancellationToken ct = default)
79+
{
80+
var updated = await _projectRoleRepository.UpdateFavoriteAsync(id, user.Id, favorite, ct);
81+
if (!updated)
82+
return null;
83+
84+
var projects = await _projectRepository.GetByIdsAsync([id], withTracking: false, filterPredicate: UserFilter(user), ct: ct);
85+
return projects.FirstOrDefault()?.ToOutgoingDto(user.Id);
7686
}
7787

7888
public async Task DeleteAsync(List<Guid> ids, UserOutgoingDto user, CancellationToken ct = default)
@@ -83,15 +93,15 @@ public async Task DeleteAsync(List<Guid> ids, UserOutgoingDto user, Cancellation
8393
public async Task<List<ProjectOutgoingDto>> GetAsync(List<Guid> ids, UserOutgoingDto user, CancellationToken ct = default)
8494
{
8595
var projects = await _projectRepository.GetByIdsAsync(ids, withTracking: false, filterPredicate: UserFilter(user), ct: ct);
86-
var dtos = projects.ToOutgoingDtos();
96+
var dtos = projects.ToOutgoingDtos(user.Id);
8797
RegisterPublicProjectsInCache(dtos);
8898
return dtos;
8999
}
90100

91101
public async Task<List<ProjectOutgoingDto>> GetAllAsync(UserOutgoingDto user, CancellationToken ct = default)
92102
{
93103
var projects = await _projectRepository.GetAllAsync(withTracking: false, filterPredicate: UserFilter(user), ct: ct);
94-
var dtos = projects.ToOutgoingDtos();
104+
var dtos = projects.ToOutgoingDtos(user.Id);
95105
RegisterPublicProjectsInCache(dtos);
96106
return dtos;
97107
}
@@ -100,13 +110,13 @@ public async Task<List<PopulatedProjectDto>> GetPopulatedAsync(List<Guid> ids, U
100110
{
101111
var projects = await _projectRepository.GetByIdsAsync(ids, withTracking: false, filterPredicate: UserFilter(user), ct: ct);
102112

103-
return projects.ToPopulatedDtos();
113+
return projects.ToPopulatedDtos(user.Id);
104114
}
105115

106116
public async Task<List<PopulatedProjectDto>> GetAllPopulatedAsync(UserOutgoingDto user, CancellationToken ct = default)
107117
{
108118
var projects = await _projectRepository.GetAllAsync(withTracking: false, filterPredicate: UserFilter(user), ct: ct);
109-
return projects.ToPopulatedDtos();
119+
return projects.ToPopulatedDtos(user.Id);
110120
}
111121

112122
private async Task EnsureDefaultSheetsExists(List<Guid> projectIds, UserOutgoingDto user, CancellationToken ct = default)

PrismaDotnetApi/PrismaApi.Domain/Dtos/ProjectDtos.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ public class ProjectDto
2222
public DateTimeOffset EndDate { get; set; } = DateTimeOffset.UtcNow.AddDays(30);
2323
}
2424

25+
public class ProjectFavoriteIncomingDto
26+
{
27+
[JsonPropertyName("favorite")]
28+
public bool Favorite { get; set; }
29+
}
30+
2531
public class ProjectCreateDto : ProjectDto
2632
{
2733
[JsonPropertyName("board_nodes")]
@@ -41,6 +47,8 @@ public class ProjectIncomingDto : ProjectDto
4147

4248
public class ProjectOutgoingDto : ProjectDto
4349
{
50+
[JsonPropertyName("favorite")]
51+
public bool Favorite { get; set; }
4452

4553
[JsonPropertyName("board_nodes")]
4654
public List<BoardNodeOutgoingDto> BoardNodes { get; set; } = new();
@@ -51,6 +59,9 @@ public class ProjectOutgoingDto : ProjectDto
5159

5260
public class PopulatedProjectDto : ProjectDto
5361
{
62+
[JsonPropertyName("favorite")]
63+
public bool Favorite { get; set; }
64+
5465
[JsonPropertyName("strategies")]
5566
public List<StrategyOutgoingDto> Strategies { get; set; } = new();
5667
[JsonPropertyName("objectives")]

PrismaDotnetApi/PrismaApi.Domain/Entities/ProjectRole.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public class ProjectRole : AuditableEntity, IBaseEntity<Guid>
1010
public required Guid ProjectId { get; set; }
1111
public required string UserId { get; set; }
1212
public string Role { get; set; } = string.Empty;
13+
public bool Favorite { get; set; } = false;
1314

1415
public Project? Project { get; set; }
1516
public User? User { get; set; }

PrismaDotnetApi/PrismaApi.Test/ControllerTests/ProjectsControllerTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,124 @@ public async Task UpdateProjects_UpdatesProject()
128128
Assert.Contains(response.Value, project => project.Id == _fixture.TestArgs.TestProjectId && project.Name == updatedName);
129129
}
130130

131+
[Fact]
132+
public async Task UpdateFavorite_UpdatesOnlyCurrentUser()
133+
{
134+
var projectId = Guid.NewGuid();
135+
using (var scope = _fixture.UserScope())
136+
{
137+
var createResponse = await Client.TestClientPostAsync<List<ProjectOutgoingDto>>("projects",
138+
new List<ProjectCreateDto>
139+
{
140+
new()
141+
{
142+
Id = projectId,
143+
Name = "Shared Project",
144+
Users =
145+
[
146+
new ProjectRoleCreateDto
147+
{
148+
Id = Guid.NewGuid(),
149+
ProjectId = projectId,
150+
UserId = _fixture.SecondaryUser.Id!,
151+
Name = _fixture.SecondaryUser.Name!,
152+
Role = ProjectRoleType.Member.ToString()
153+
}
154+
]
155+
}
156+
});
157+
158+
Assert.Equal(HttpStatusCode.OK, createResponse.Response.StatusCode);
159+
160+
var favoriteResponse = await Client.TestClientPatchAsync<ProjectOutgoingDto>(
161+
$"projects/{projectId}/favorite",
162+
new ProjectFavoriteIncomingDto { Favorite = true });
163+
164+
Assert.Equal(HttpStatusCode.OK, favoriteResponse.Response.StatusCode);
165+
Assert.True(favoriteResponse.Value.Favorite);
166+
}
167+
168+
using (var scope = _fixture.SecondaryUserScope())
169+
{
170+
var secondaryResponse = await Client.TestClientGetAsync<ProjectOutgoingDto>($"projects/{projectId}");
171+
172+
Assert.Equal(HttpStatusCode.OK, secondaryResponse.Response.StatusCode);
173+
Assert.False(secondaryResponse.Value.Favorite);
174+
}
175+
}
176+
177+
[Fact]
178+
public async Task DuplicateProject_ResetsFavorite()
179+
{
180+
using var scope = _fixture.UserScope();
181+
182+
var favoriteResponse = await Client.TestClientPatchAsync<ProjectOutgoingDto>(
183+
$"projects/{_fixture.TestArgs.TestProjectId}/favorite",
184+
new ProjectFavoriteIncomingDto { Favorite = true });
185+
Assert.Equal(HttpStatusCode.OK, favoriteResponse.Response.StatusCode);
186+
187+
var duplicateResponse = await Client.TestClientPostNoPayloadAsync<ProjectOutgoingDto>(
188+
$"projects/{_fixture.TestArgs.TestProjectId}/duplicate");
189+
190+
Assert.Equal(HttpStatusCode.OK, duplicateResponse.Response.StatusCode);
191+
Assert.False(duplicateResponse.Value.Favorite);
192+
}
193+
194+
[Fact]
195+
public async Task ImportProject_ResetsFavoriteForAllUsers()
196+
{
197+
var sourceProjectId = Guid.NewGuid();
198+
Guid importedProjectId;
199+
200+
using (var scope = _fixture.UserScope())
201+
{
202+
var importResponse = await Client.TestClientPostAsync<List<ProjectOutgoingDto>>("projects/import",
203+
new List<ProjectImportDto>
204+
{
205+
new()
206+
{
207+
Projects = new ProjectIncomingDto
208+
{
209+
Id = sourceProjectId,
210+
Name = "Imported Project",
211+
Users =
212+
[
213+
new ProjectRoleIncomingDto
214+
{
215+
Id = Guid.NewGuid(),
216+
ProjectId = sourceProjectId,
217+
UserId = _fixture.PrismaUser.Id!,
218+
Name = _fixture.PrismaUser.Name!,
219+
Role = ProjectRoleType.Facilitator.ToString()
220+
},
221+
new ProjectRoleIncomingDto
222+
{
223+
Id = Guid.NewGuid(),
224+
ProjectId = sourceProjectId,
225+
UserId = _fixture.SecondaryUser.Id!,
226+
Name = _fixture.SecondaryUser.Name!,
227+
Role = ProjectRoleType.Member.ToString()
228+
}
229+
]
230+
}
231+
}
232+
});
233+
234+
Assert.Equal(HttpStatusCode.OK, importResponse.Response.StatusCode);
235+
var importedProject = Assert.Single(importResponse.Value);
236+
Assert.False(importedProject.Favorite);
237+
importedProjectId = importedProject.Id;
238+
}
239+
240+
using (var scope = _fixture.SecondaryUserScope())
241+
{
242+
var secondaryResponse = await Client.TestClientGetAsync<ProjectOutgoingDto>($"projects/{importedProjectId}");
243+
244+
Assert.Equal(HttpStatusCode.OK, secondaryResponse.Response.StatusCode);
245+
Assert.False(secondaryResponse.Value.Favorite);
246+
}
247+
}
248+
131249
[Fact]
132250
public async Task DeleteProject_RemovesProject()
133251
{

0 commit comments

Comments
 (0)