From d7fcbfd4d9b4d26cacc4589d394a56fd3189ca03 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 14 May 2026 14:57:06 +0200 Subject: [PATCH] feat(groups): prevent deletion of groups referenced by active invites Adds GROUP_HAS_ACTIVE_INVITES error code and guards UserService.deleteGroup() with a 409 conflict when any active (non-revoked, non-expired, non-exhausted) invite token still holds the group UUID. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 2 ++ .../user/InviteTokenRepository.java | 3 +++ .../familienarchiv/user/UserService.java | 5 ++++ .../familienarchiv/user/UserServiceTest.java | 24 +++++++++++++++++++ 4 files changed, 34 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 63b4afe4..46ad1cd0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -52,6 +52,8 @@ public enum ErrorCode { INVITE_REVOKED, /** The invite has passed its expiry date. 410 */ INVITE_EXPIRED, + /** A group cannot be deleted because one or more active invites reference it. 409 */ + GROUP_HAS_ACTIVE_INVITES, // --- Auth --- /** The request is not authenticated. 401 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java index 07771f28..7e385c34 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/InviteTokenRepository.java @@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository @Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC") List findAllOrderedByCreatedAt(); + + @Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)") + boolean existsActiveWithGroupId(@Param("groupId") UUID groupId); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java index 09e3493e..96cb98df 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/user/UserService.java @@ -37,6 +37,7 @@ public class UserService { private final AppUserRepository userRepository; private final UserGroupRepository groupRepository; + private final InviteTokenRepository inviteTokenRepository; private final PasswordEncoder passwordEncoder; private final AuditService auditService; @@ -288,6 +289,10 @@ public class UserService { @Transactional public void deleteGroup(UUID id) { + if (inviteTokenRepository.existsActiveWithGroupId(id)) { + throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES, + "Cannot delete group " + id + " — referenced by one or more active invites"); + } groupRepository.deleteById(id); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java index eee87ed9..46341231 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/user/UserServiceTest.java @@ -36,6 +36,7 @@ class UserServiceTest { @Mock AppUserRepository userRepository; @Mock UserGroupRepository groupRepository; + @Mock InviteTokenRepository inviteTokenRepository; @Mock PasswordEncoder passwordEncoder; @Mock AuditService auditService; @InjectMocks UserService userService; @@ -903,6 +904,29 @@ class UserServiceTest { assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL"); } + // ─── deleteGroup ────────────────────────────────────────────────────────── + + @Test + void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() { + UUID groupId = UUID.randomUUID(); + when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true); + + assertThatThrownBy(() -> userService.deleteGroup(groupId)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES); + } + + @Test + void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() { + UUID groupId = UUID.randomUUID(); + when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false); + + userService.deleteGroup(groupId); + + verify(groupRepository).deleteById(groupId); + } + @Test void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() { org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();