diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java index d88d96cfc..36c04f09c 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java @@ -72,6 +72,16 @@ public interface TeamMembershipRepository extends JpaRepository findByTeamIdAndRole( @Param("teamId") Long teamId, @Param("role") TeamRole role); + /** + * Count members with a specific role in a team. Lighter than {@link #findByTeamIdAndRole} when + * only the tally is needed (e.g. last-leader checks) — avoids fetching and join-loading rows. + * + * @param teamId the team ID + * @param role the team role (LEADER or MEMBER) + * @return number of members with that role + */ + long countByTeamIdAndRole(Long teamId, TeamRole role); + /** * Check if a user is a member of a team * diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 02bf87e27..43637c2d0 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -303,6 +303,11 @@ public class SaasTeamService { throw new IllegalStateException("Team has no available seats"); } + // Validate: accepting won't orphan a team the user leads or that has a paid plan. + // Accepting moves the user off their current team; leaveTeam already blocks the + // last leader of a team from walking away, so accept must enforce the same rule. + assertCanLeaveCurrentTeamsToJoinAnother(acceptingUser); + // User can only be in one team . leave existing teams before joining new one List existingMemberships = membershipRepository.findByUserId(acceptingUser.getId()); @@ -442,6 +447,46 @@ public class SaasTeamService { teamId); } + /** + * Guard against silently orphaning a team when a user accepts an invite to another one. + * + *

{@link #acceptInvitation} moves a user to the inviting team by first leaving their current + * team(s). Personal teams are disposable (they get deleted on accept), but a non-personal team + * must not be left memberless while still billing. {@link #leaveTeam} already refuses to let + * the last leader walk away; accept took a shortcut around that check, which let a paid team's + * leader join another team and orphan their old team together with its live subscription. + * + *

So: for each non-personal team the user leads as its last leader, block the + * accept. The message points them at the right remedy — cancel the plan if the team is paid, + * otherwise transfer leadership first. + * + * @param user the user attempting to accept an invitation + * @throws IllegalStateException if accepting would orphan a team the user leads + */ + private void assertCanLeaveCurrentTeamsToJoinAnother(User user) { + for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) { + Team team = membership.getTeam(); + if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) { + // Personal teams are deleted on accept; non-leaders leaving never orphans a team. + continue; + } + // Only reached for a non-personal team the user leads — at most one such team in the + // one-team-per-user model — so this count runs ~once, not per membership. + if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) { + // Another leader remains, so the team keeps an owner. + continue; + } + if (hasActivePaidSubscription(team)) { + throw new IllegalStateException( + "Your team has an active plan and you are its last leader. Cancel the plan" + + " or transfer leadership before joining another team."); + } + throw new IllegalStateException( + "You are the last leader of your team. Transfer leadership before joining" + + " another team."); + } + } + /** * Leave team (self-removal) *