feat(members): implement /members page — Kachel-Ansicht (E2, issue #48) #58
@@ -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<ApiResponse<InviteResponse>> getActiveInvite(Principal principal) {
|
||||
Optional<InviteResponse> invite = householdService.getActiveInvite(principal.getName());
|
||||
return invite
|
||||
.map(r -> ResponseEntity.ok(ApiResponse.success(r)))
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PostMapping("/households/mine/invites")
|
||||
public ResponseEntity<ApiResponse<InviteResponse>> 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<Void> removeMember(Principal principal, @PathVariable UUID userId) {
|
||||
householdService.removeMember(principal.getName(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PatchMapping("/households/mine/members/{userId}")
|
||||
public ResponseEntity<ApiResponse<MemberResponse>> 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<ApiResponse<AcceptInviteResponse>> acceptInvite(
|
||||
Principal principal,
|
||||
|
||||
@@ -8,4 +8,5 @@ import java.util.UUID;
|
||||
|
||||
public interface HouseholdInviteRepository extends JpaRepository<HouseholdInvite, UUID> {
|
||||
Optional<HouseholdInvite> findByInviteCode(String inviteCode);
|
||||
Optional<HouseholdInvite> findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ import java.util.UUID;
|
||||
public interface HouseholdMemberRepository extends JpaRepository<HouseholdMember, UUID> {
|
||||
Optional<HouseholdMember> findByUserEmailIgnoreCase(String email);
|
||||
List<HouseholdMember> findByHouseholdId(UUID householdId);
|
||||
Optional<HouseholdMember> findByHouseholdIdAndUserId(UUID householdId, UUID userId);
|
||||
long countByHouseholdIdAndRole(UUID householdId, String role);
|
||||
}
|
||||
|
||||
@@ -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<InviteResponse> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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<InviteResponse> 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<InviteResponse> 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<InviteResponse> 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user