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 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 18:41:38 +02:00
parent 27163e3d72
commit d1e4b6c49e
9 changed files with 349 additions and 4 deletions

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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