From d1e4b6c49e838121f4f9e33940d81192b18d8fab Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:41:38 +0200 Subject: [PATCH] feat(members): implement DELETE/PATCH member + GET invites backend endpoints - Add V006 migration: invalidated_at column + partial unique index on household_invite - Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole - Add ChangeRoleRequest DTO - HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole - HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId} Co-Authored-By: Claude Sonnet 4.6 --- .../household/HouseholdController.java | 25 +++ .../household/HouseholdInviteRepository.java | 1 + .../household/HouseholdMemberRepository.java | 2 + .../recipeapp/household/HouseholdService.java | 69 ++++++- .../household/dto/ChangeRoleRequest.java | 10 + .../household/entity/HouseholdInvite.java | 5 + .../V006__add_invite_invalidated_at.sql | 6 + .../household/HouseholdControllerTest.java | 54 ++++++ .../household/HouseholdServiceTest.java | 181 ++++++++++++++++++ 9 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java create mode 100644 backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 7f704d7..46c8556 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.util.List; +import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/v1") @@ -40,12 +42,35 @@ public class HouseholdController { return ResponseEntity.ok(members); } + @GetMapping("/households/mine/invites") + public ResponseEntity> getActiveInvite(Principal principal) { + Optional invite = householdService.getActiveInvite(principal.getName()); + return invite + .map(r -> ResponseEntity.ok(ApiResponse.success(r))) + .orElse(ResponseEntity.noContent().build()); + } + @PostMapping("/households/mine/invites") public ResponseEntity> createInvite(Principal principal) { InviteResponse response = householdService.createInvite(principal.getName()); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } + @DeleteMapping("/households/mine/members/{userId}") + public ResponseEntity removeMember(Principal principal, @PathVariable UUID userId) { + householdService.removeMember(principal.getName(), userId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/households/mine/members/{userId}") + public ResponseEntity> changeMemberRole( + Principal principal, + @PathVariable UUID userId, + @Valid @RequestBody ChangeRoleRequest request) { + MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + @PostMapping("/invites/{code}/accept") public ResponseEntity> acceptInvite( Principal principal, diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java index 7486172..03dd511 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface HouseholdInviteRepository extends JpaRepository { Optional findByInviteCode(String inviteCode); + Optional findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java index 80853b1..32a24de 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java @@ -10,4 +10,6 @@ import java.util.UUID; public interface HouseholdMemberRepository extends JpaRepository { Optional findByUserEmailIgnoreCase(String email); List findByHouseholdId(UUID householdId); + Optional findByHouseholdIdAndUserId(UUID householdId, UUID userId); + long countByHouseholdIdAndRole(UUID householdId, String role); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 242f1d5..678bf67 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.Instant; import java.util.List; +import java.util.Optional; +import java.util.UUID; @Service public class HouseholdService { @@ -91,21 +93,73 @@ public class HouseholdService { .toList(); } + @Transactional + public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) { + HouseholdMember requester = findMembership(requesterEmail); + UUID householdId = requester.getHousehold().getId(); + + HouseholdMember target = householdMemberRepository + .findByHouseholdIdAndUserId(householdId, targetUserId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found in this household")); + + if (target.getRole().equals(newRole)) { + return toMemberResponse(target); + } + + if ("member".equals(newRole) && "planner".equals(target.getRole())) { + long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner"); + if (plannerCount <= 1) { + throw new ConflictException("Cannot degrade the last planner"); + } + } + + target.setRole(newRole); + return toMemberResponse(householdMemberRepository.save(target)); + } + + @Transactional + public void removeMember(String requesterEmail, UUID targetUserId) { + HouseholdMember requester = findMembership(requesterEmail); + UUID householdId = requester.getHousehold().getId(); + + HouseholdMember target = householdMemberRepository + .findByHouseholdIdAndUserId(householdId, targetUserId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found in this household")); + + if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) { + throw new ConflictException("Planner cannot remove yourself"); + } + + householdMemberRepository.delete(target); + } + + @Transactional(readOnly = true) + public Optional getActiveInvite(String userEmail) { + HouseholdMember member = findMembership(userEmail); + return householdInviteRepository + .findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId()) + .filter(invite -> invite.getExpiresAt().isAfter(Instant.now())) + .map(this::toInviteResponse); + } + @Transactional public InviteResponse createInvite(String userEmail) { HouseholdMember member = findMembership(userEmail); Household household = member.getHousehold(); + householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) + .ifPresent(existing -> { + existing.setInvalidatedAt(Instant.now()); + householdInviteRepository.save(existing); + }); + String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); HouseholdInvite invite = householdInviteRepository.save( new HouseholdInvite(household, code, expiresAt)); - return new InviteResponse( - invite.getInviteCode(), - "https://yourapp.com/join/" + invite.getInviteCode(), - invite.getExpiresAt()); + return toInviteResponse(invite); } @Transactional @@ -204,4 +258,11 @@ public class HouseholdService { member.getRole(), member.getJoinedAt()); } + + private InviteResponse toInviteResponse(HouseholdInvite invite) { + return new InviteResponse( + invite.getInviteCode(), + "https://yourapp.com/join/" + invite.getInviteCode(), + invite.getExpiresAt()); + } } diff --git a/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java new file mode 100644 index 0000000..80e9c95 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java @@ -0,0 +1,10 @@ +package com.recipeapp.household.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ChangeRoleRequest( + @NotBlank + @Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'") + String role +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 916fac1..04246e6 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -25,6 +25,9 @@ public class HouseholdInvite { @Column(name = "expires_at", nullable = false) private Instant expiresAt; + @Column(name = "invalidated_at") + private Instant invalidatedAt; + protected HouseholdInvite() {} public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { @@ -39,4 +42,6 @@ public class HouseholdInvite { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public Instant getExpiresAt() { return expiresAt; } + public Instant getInvalidatedAt() { return invalidatedAt; } + public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; } } diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql new file mode 100644 index 0000000..c9a5511 --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql @@ -0,0 +1,6 @@ +ALTER TABLE household_invite + ADD COLUMN invalidated_at timestamptz; + +CREATE UNIQUE INDEX uq_household_invite_active + ON household_invite (household_id) + WHERE invalidated_at IS NULL; diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index ceee167..1a33db3 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -103,6 +105,58 @@ class HouseholdControllerTest { .andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ")); } + @Test + void getActiveInviteShouldReturn200WithInvite() throws Exception { + var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12", + Instant.now().plusSeconds(172800)); + + when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response)); + + mockMvc.perform(get("/v1/households/mine/invites") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12")); + } + + @Test + void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception { + when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/v1/households/mine/invites") + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + } + + @Test + void deleteMemberShouldReturn204() throws Exception { + var memberId = UUID.randomUUID(); + + mockMvc.perform(delete("/v1/households/mine/members/" + memberId) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + + verify(householdService).removeMember("sarah@example.com", memberId); + } + + @Test + void patchMemberRoleShouldReturn200() throws Exception { + var memberId = UUID.randomUUID(); + var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now()); + var request = new ChangeRoleRequest("planner"); + + when(householdService.changeMemberRole("sarah@example.com", memberId, "planner")) + .thenReturn(memberResponse); + + mockMvc.perform(patch("/v1/households/mine/members/" + memberId) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.role").value("planner")); + } + @Test void acceptInviteShouldReturn200() throws Exception { var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index baf3c32..9b00fbb 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -223,6 +224,169 @@ class HouseholdServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + // ── changeMemberRole ────────────────────────────────────────────────────── + + @Test + void changeMemberRoleShouldUpdateRole() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); + + MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner"); + + assertThat(result.role()).isEqualTo("planner"); + } + + @Test + void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + + MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member"); + + assertThat(result.role()).isEqualTo("member"); + verify(householdMemberRepository, never()).save(any()); + } + + @Test + void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L); + + assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member")) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("last planner"); + } + + @Test + void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var unknownId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner")) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── removeMember ────────────────────────────────────────────────────────── + + @Test + void removeMemberShouldDeleteMember() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + + householdService.removeMember("sarah@example.com", targetId); + + verify(householdMemberRepository).delete(targetMembership); + } + + @Test + void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var plannerId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership)); + + assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("cannot remove yourself"); + } + + @Test + void removeMemberShouldThrow404WhenTargetNotInHousehold() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var unknownId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── getActiveInvite ─────────────────────────────────────────────────────── + + @Test + void getActiveInviteShouldReturnActiveInviteResponse() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite)); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isPresent(); + assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123"); + } + + @Test + void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty()); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + + @Test + void getActiveInviteShouldReturnEmptyWhenExpired() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite)); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + @Test void getMembersShouldReturnAllMembers() { var user1 = testUser(); @@ -256,4 +420,21 @@ class HouseholdServiceTest { assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) .isInstanceOf(ResourceNotFoundException.class); } + + @Test + void createInviteShouldInvalidatePreviousActiveInvite() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite)); + when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); + + householdService.createInvite("sarah@example.com"); + + assertThat(existingInvite.getInvalidatedAt()).isNotNull(); + verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class)); + } }