diff --git a/pom.xml b/pom.xml index 1aadee48..979a69ab 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ 26.7.2 7.7.0 1.38-8c9b968 - 1.685-5c25c2e + 1.699-7280b62 5.0.0 3.0.0 @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.18-6b1eb95 + 1.19-e5d3e5f dev.vality diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java new file mode 100644 index 00000000..c48adffd --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -0,0 +1,196 @@ +package dev.vality.orgmanager.converter; + +import dev.vality.orgmanagement.Invitation; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.Member; +import dev.vality.orgmanagement.MemberRole; +import dev.vality.orgmanagement.Organization; +import dev.vality.orgmanagement.OrganizationRole; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.RoleAssignment; +import dev.vality.orgmanagement.RoleScope; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.entity.StoredInvitationStatus; +import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +@Component +public class AdminManagementConverter { + + private static final String NULL_METADATA = "null"; + + public Organization toOrganization(OrganizationEntity entity) { + Organization organization = new Organization( + entity.getId(), + entity.getParty(), + entity.getOwner(), + entity.getName(), + formatDateTime(entity.getCreatedAt()), + toOrganizationStatus(entity.getStatus())); + String metadata = metadataOrNull(entity.getMetadata()); + if (metadata != null) { + organization.setMetadata(metadata); + } + return organization; + } + + public Member toMember(MemberEntity entity, String organizationId) { + List roles = collectionOrEmpty(entity.getRoles()).stream() + .filter(MemberRoleEntity::isActive) + .filter(role -> organizationId.equals(role.getOrganizationId())) + .sorted(Comparator.comparing(MemberRoleEntity::getId)) + .map(this::toMemberRole) + .toList(); + Member member = new Member(entity.getId(), roles); + if (entity.getEmail() != null) { + member.setEmail(entity.getEmail()); + } + return member; + } + + /** + * Собирает участников из плоской выборки {@code member x member_role}. Строки без + * memberRoleId — участники без активных ролей в организации, у них список ролей пустой. + */ + public List toMembers(List rows) { + Map members = new LinkedHashMap<>(); + for (MemberWithRoleDto row : collectionOrEmpty(rows)) { + Member member = members.computeIfAbsent(row.getId(), id -> { + Member created = new Member(id, new ArrayList<>()); + if (row.getEmail() != null) { + created.setEmail(row.getEmail()); + } + return created; + }); + if (row.getMemberRoleId() != null) { + member.getRoles().add(toMemberRole(row)); + } + } + return List.copyOf(members.values()); + } + + public MemberRole toMemberRole(MemberRoleEntity entity) { + MemberRole role = new MemberRole(entity.getId(), entity.getRoleId()); + if (entity.getScopeId() != null) { + role.setScope(toRoleScope(entity.getScopeId(), entity.getResourceId())); + } + return role; + } + + private MemberRole toMemberRole(MemberWithRoleDto row) { + MemberRole role = new MemberRole(row.getMemberRoleId(), row.getRoleId()); + if (row.getScopeId() != null) { + role.setScope(toRoleScope(row.getScopeId(), row.getResourceId())); + } + return role; + } + + public RoleAssignment toRoleAssignment(MemberRoleEntity entity) { + RoleAssignment assignment = new RoleAssignment(entity.getRoleId()); + if (entity.getScopeId() != null) { + assignment.setScope(toRoleScope(entity.getScopeId(), entity.getResourceId())); + } + return assignment; + } + + public Invitation toInvitation(InvitationEntity entity) { + List roles = collectionOrEmpty(entity.getInviteeRoles()).stream() + .sorted(Comparator.comparing(MemberRoleEntity::getId)) + .map(this::toRoleAssignment) + .toList(); + Invitation invitation = new Invitation( + entity.getId(), + entity.getOrganizationId(), + formatDateTime(entity.getCreatedAt()), + formatDateTime(entity.getExpiresAt()), + entity.getInviteeContactEmail(), + roles, + effectiveInvitationStatus(entity)); + String metadata = metadataOrNull(entity.getMetadata()); + if (metadata != null) { + invitation.setMetadata(metadata); + } + if (entity.getAcceptedAt() != null) { + invitation.setAcceptedAt(formatDateTime(entity.getAcceptedAt())); + } + if (entity.getAcceptedMemberId() != null) { + invitation.setAcceptedMemberId(entity.getAcceptedMemberId()); + } + if (entity.getRevokedAt() != null) { + invitation.setRevokedAt(formatDateTime(entity.getRevokedAt())); + } + if (entity.getRevocationReason() != null) { + invitation.setRevocationReason(entity.getRevocationReason()); + } + return invitation; + } + + public InvitationStatus effectiveInvitationStatus(InvitationEntity entity) { + InvitationStatus storedStatus = toInvitationStatus(entity.getStatus()); + if (storedStatus == InvitationStatus.pending && entity.isExpired()) { + return InvitationStatus.expired; + } + return storedStatus; + } + + public OrganizationRole toOrganizationRole(OrganizationRoleEntity entity) { + List scopeIds = collectionOrEmpty(entity.getPossibleScopes()).stream() + .map(scope -> scope.getId()) + .filter(Objects::nonNull) + .sorted() + .toList(); + return new OrganizationRole(entity.getRoleId(), entity.getName(), scopeIds); + } + + private RoleScope toRoleScope(String scopeId, String resourceId) { + RoleScope scope = new RoleScope(scopeId); + if (resourceId != null) { + scope.setResourceId(resourceId); + } + return scope; + } + + private OrganizationStatus toOrganizationStatus(String status) { + if (status == null) { + return OrganizationStatus.active; + } + return OrganizationStatus.valueOf(status.toLowerCase(Locale.ROOT)); + } + + private InvitationStatus toInvitationStatus(String status) { + return switch (StoredInvitationStatus.fromValue(status)) { + case PENDING -> InvitationStatus.pending; + case ACCEPTED -> InvitationStatus.accepted; + case EXPIRED -> InvitationStatus.expired; + case REVOKED -> InvitationStatus.revoked; + }; + } + + + private String metadataOrNull(String metadata) { + return metadata == null || NULL_METADATA.equals(metadata.strip()) ? null : metadata; + } + + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.atOffset(ZoneOffset.UTC).toString(); + } + + private Collection collectionOrEmpty(Collection collection) { + return collection == null ? List.of() : collection; + } +} diff --git a/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java b/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java index d91232e0..277d989d 100644 --- a/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java @@ -2,6 +2,7 @@ import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.util.JsonCodec; import dev.vality.swag.organizations.model.*; import lombok.RequiredArgsConstructor; @@ -49,7 +50,7 @@ public InvitationEntity toEntity(InvitationRequest invitation, String orgId) { .map(role -> memberRoleConverter.toEntity(role, orgId)) .collect(toSet())) .metadata(jsonCodec.toJson(invitation.getMetadata())) - .status(InvitationStatusName.PENDING.getValue()) + .status(StoredInvitationStatus.PENDING.getValue()) .acceptToken(UUID.randomUUID().toString()) // TODO [a.romanov]: token .build(); } diff --git a/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java b/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java index c5ccf292..e32f09eb 100644 --- a/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java +++ b/src/main/java/dev/vality/orgmanager/entity/OrganizationEntity.java @@ -42,4 +42,6 @@ public class OrganizationEntity implements Serializable { private String owner; private String party; private String metadata; + @Builder.Default + private String status = "active"; } diff --git a/src/main/java/dev/vality/orgmanager/entity/StoredInvitationStatus.java b/src/main/java/dev/vality/orgmanager/entity/StoredInvitationStatus.java new file mode 100644 index 00000000..fe8a06a6 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/entity/StoredInvitationStatus.java @@ -0,0 +1,35 @@ +package dev.vality.orgmanager.entity; + +import java.util.Arrays; + +/** + * Значения колонки {@code invitation.status}. + */ +public enum StoredInvitationStatus { + + PENDING("Pending"), + ACCEPTED("Accepted"), + EXPIRED("Expired"), + REVOKED("Revoked"); + + private final String value; + + StoredInvitationStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public boolean matches(String status) { + return value.equalsIgnoreCase(status); + } + + public static StoredInvitationStatus fromValue(String status) { + return Arrays.stream(values()) + .filter(candidate -> candidate.matches(status)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown invitation status: " + status)); + } +} diff --git a/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java b/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java new file mode 100644 index 00000000..309e4798 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java @@ -0,0 +1,16 @@ +package dev.vality.orgmanager.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Значения колонки {@code invitation.invitee_contact_type}. + */ +@Getter +@RequiredArgsConstructor +public enum StoredInviteeContactType { + + EMAIL("EMail"); + + private final String value; +} diff --git a/src/main/java/dev/vality/orgmanager/entity/StoredOrganizationStatus.java b/src/main/java/dev/vality/orgmanager/entity/StoredOrganizationStatus.java new file mode 100644 index 00000000..f54dcc77 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/entity/StoredOrganizationStatus.java @@ -0,0 +1,30 @@ +package dev.vality.orgmanager.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * Значения колонки {@code organization.status}. + */ +@Getter +@RequiredArgsConstructor +public enum StoredOrganizationStatus { + + ACTIVE("active"), + DEACTIVATED("deactivated"); + + private final String value; + + public boolean matches(String status) { + return value.equalsIgnoreCase(status); + } + + public static StoredOrganizationStatus fromValue(String status) { + return Arrays.stream(values()) + .filter(candidate -> candidate.matches(status)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown organization status: " + status)); + } +} diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index 7435e685..a8b2cb83 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -32,6 +32,32 @@ public interface MemberRepository extends JpaRepository { " AND m.id = mtmr.member_id ") List getOrgMemberList(String orgId); + /** + * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний: участник без активных + * ролей в организации тоже попадает в выдачу. Административный контракт умеет добавлять + * участника без роли (AddMember), и такой участник не должен пропадать из списка. + * У строк для участника без ролей memberRoleId == null. + */ + @NativeQuery("SELECT m.id, " + + " m.email, " + + " mr.id as memberRoleId, " + + " mr.organization_id as organizationId, " + + " mr.role_id as roleId, " + + " mr.scope_id as scopeId, " + + " mr.resource_id as resourceId" + + " FROM org_manager.member_to_organization mto " + + " JOIN org_manager.member m " + + " ON m.id = mto.member_id " + + " LEFT JOIN org_manager.member_to_member_role mtmr " + + " ON mtmr.member_id = m.id " + + " LEFT JOIN org_manager.member_role mr " + + " ON mr.id = mtmr.member_role_id " + + " AND mr.active IS TRUE " + + " AND mr.organization_id = mto.organization_id " + + " WHERE mto.organization_id = ?1 " + + " ORDER BY m.id, mr.id ") + List getOrgMemberListWithRoles(String orgId); + boolean existsById(String id); diff --git a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java index a5581c2c..808daa29 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -3,6 +3,7 @@ import dev.vality.orgmanager.entity.OrganizationEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; @@ -36,4 +37,17 @@ public interface OrganizationRepository extends JpaRepository findAllByOwner(String owner); + boolean existsByParty(String party); + + List findAllByOrderByIdDesc(Pageable pageable); + + List findByIdLessThanOrderByIdDesc(String id, Pageable pageable); + + List findByStatusOrderByIdDesc(String status, Pageable pageable); + + List findByStatusAndIdLessThanOrderByIdDesc( + String status, + String id, + Pageable pageable); + } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java new file mode 100644 index 00000000..ae334e54 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -0,0 +1,53 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.RoleScope; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.util.JsonCodec; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AdminCommonService { + + private final OrganizationRepository organizationRepository; + private final JsonCodec jsonCodec; + + public OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { + return organizationRepository.findById(organizationId) + .orElseThrow(OrganizationNotFound::new); + } + + public String toStoredMetadata(String metadata) { + if (metadata == null) { + return null; + } + try { + return jsonCodec.toJson(jsonCodec.toMap(metadata)); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("Metadata is expected to be a JSON object", exception); + } + } + + public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, RoleScope scope) { + return MemberRoleEntity.builder() + .id(UUID.randomUUID().toString()) + .organizationId(organizationId) + .roleId(roleId) + .scopeId(scope == null ? null : scope.getScopeId()) + .resourceId(scope == null ? null : scope.getResourceId()) + .active(true) + .build(); + } + + public static Collection collectionOrEmpty(Collection collection) { + return collection == null ? List.of() : collection; + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java new file mode 100644 index 00000000..098fd975 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -0,0 +1,110 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.CreateInvitationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.Invitation; +import dev.vality.orgmanagement.InvitationNotFound; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanager.config.properties.InviteTokenProperties; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.StoredInvitationStatus; +import dev.vality.orgmanager.entity.StoredInviteeContactType; +import dev.vality.orgmanager.repository.InvitationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Приглашения в организации в административном контракте. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminInvitationService { + + private final InvitationRepository invitationRepository; + private final InviteTokenProperties inviteTokenProperties; + private final MailMessageSender mailMessageSender; + private final AdminManagementConverter converter; + private final AdminCommonService commonService; + + @Transactional + public Invitation create(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound { + log.info("Create invitation: organizationId={}, email={}", organizationId, request.getEmail()); + commonService.findOrganization(organizationId); + Set roles = request.getRoles().stream() + .map(role -> commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + LocalDateTime now = LocalDateTime.now(); + InvitationEntity invitation = InvitationEntity.builder() + .id(UUID.randomUUID().toString()) + .organizationId(organizationId) + .createdAt(now) + .expiresAt(now.plusDays(inviteTokenProperties.getLifeTimeInDays())) + .acceptToken(UUID.randomUUID().toString()) + .inviteeContactType(StoredInviteeContactType.EMAIL.getValue()) + .inviteeContactEmail(request.getEmail()) + .inviteeRoles(roles) + .metadata(commonService.toStoredMetadata(request.getMetadata())) + .status(StoredInvitationStatus.PENDING.getValue()) + .build(); + invitation = invitationRepository.save(invitation); + mailMessageSender.send(invitation.getAcceptToken(), invitation.getInviteeContactEmail()); + return converter.toInvitation(invitation); + } + + @Transactional(readOnly = true) + public Invitation get(String organizationId, String invitationId) + throws OrganizationNotFound, InvitationNotFound { + log.info("Get invitation: organizationId={}, invitationId={}", organizationId, invitationId); + commonService.findOrganization(organizationId); + return converter.toInvitation(findInvitation(organizationId, invitationId)); + } + + @Transactional(readOnly = true) + public List list(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { + log.info("List invitations: organizationId={}, request={}", organizationId, request); + commonService.findOrganization(organizationId); + InvitationStatus status = request == null ? null : request.getStatus(); + return invitationRepository.findByOrganizationId(organizationId).stream() + .filter(invitation -> status == null || converter.effectiveInvitationStatus(invitation) == status) + .sorted(Comparator.comparing(InvitationEntity::getCreatedAt).reversed()) + .map(converter::toInvitation) + .toList(); + } + + @Transactional + public void revoke(String organizationId, String invitationId, RevokeInvitationRequest request) + throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + log.info("Revoke invitation: organizationId={}, invitationId={}", organizationId, invitationId); + commonService.findOrganization(organizationId); + InvitationEntity invitation = findInvitation(organizationId, invitationId); + InvitationStatus status = converter.effectiveInvitationStatus(invitation); + if (status != InvitationStatus.pending) { + throw new InvalidOrganizationState("Only a pending invitation can be revoked"); + } + invitation.setStatus(StoredInvitationStatus.REVOKED.getValue()); + invitation.setRevocationReason(request.getReason()); + invitation.setRevokedAt(LocalDateTime.now()); + invitationRepository.save(invitation); + } + + private InvitationEntity findInvitation(String organizationId, String invitationId) throws InvitationNotFound { + return invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) + .orElseThrow(InvitationNotFound::new); + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java new file mode 100644 index 00000000..0c965fca --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -0,0 +1,133 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.AddMemberRequest; +import dev.vality.orgmanagement.AdminManagementSrv; +import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.CreateInvitationRequest; +import dev.vality.orgmanagement.CreateOrganizationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.Invitation; +import dev.vality.orgmanagement.InvitationNotFound; +import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListOrganizationsRequest; +import dev.vality.orgmanagement.ListOrganizationsResult; +import dev.vality.orgmanagement.Member; +import dev.vality.orgmanagement.MemberNotFound; +import dev.vality.orgmanagement.MemberRole; +import dev.vality.orgmanagement.MemberRoleNotFound; +import dev.vality.orgmanagement.Organization; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.OrganizationRole; +import dev.vality.orgmanagement.PartyAlreadyBound; +import dev.vality.orgmanagement.RevokeInvitationRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminManagementService implements AdminManagementSrv.Iface { + + private final AdminOrganizationService adminOrganizationService; + private final AdminMemberService adminMemberService; + private final AdminInvitationService adminInvitationService; + + @Override + public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + return adminOrganizationService.create(request); + } + + @Override + public Organization getOrganization(String organizationId) throws OrganizationNotFound { + return adminOrganizationService.get(organizationId); + } + + @Override + public ListOrganizationsResult listOrganizations(ListOrganizationsRequest request) { + return adminOrganizationService.list(request); + } + + @Override + public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { + return adminOrganizationService.rename(organizationId, name); + } + + @Override + public Organization deactivateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return adminOrganizationService.deactivate(organizationId); + } + + @Override + public Organization activateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return adminOrganizationService.activate(organizationId); + } + + @Override + public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound { + return adminOrganizationService.getRole(organizationId, roleId); + } + + @Override + public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { + return adminOrganizationService.listRoles(organizationId); + } + + @Override + public Member getMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + return adminMemberService.get(organizationId, userId); + } + + @Override + public List listMembers(String organizationId) throws OrganizationNotFound { + return adminMemberService.list(organizationId); + } + + @Override + public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + return adminMemberService.add(organizationId, request); + } + + @Override + public void removeMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + adminMemberService.remove(organizationId, userId); + } + + @Override + public MemberRole assignMemberRole(String organizationId, String userId, AssignMemberRoleRequest request) + throws OrganizationNotFound, MemberNotFound { + return adminMemberService.assignRole(organizationId, userId, request); + } + + @Override + public void removeMemberRole(String organizationId, String userId, String memberRoleId) + throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { + adminMemberService.removeRole(organizationId, userId, memberRoleId); + } + + @Override + public Invitation createInvitation(String organizationId, CreateInvitationRequest request) + throws OrganizationNotFound { + return adminInvitationService.create(organizationId, request); + } + + @Override + public Invitation getInvitation(String organizationId, String invitationId) + throws OrganizationNotFound, InvitationNotFound { + return adminInvitationService.get(organizationId, invitationId); + } + + @Override + public List listInvitations(String organizationId, ListInvitationsRequest request) + throws OrganizationNotFound { + return adminInvitationService.list(organizationId, request); + } + + @Override + public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) + throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + adminInvitationService.revoke(organizationId, invitationId, request); + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java new file mode 100644 index 00000000..9b6a2b7b --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -0,0 +1,157 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.AddMemberRequest; +import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.Member; +import dev.vality.orgmanagement.MemberNotFound; +import dev.vality.orgmanagement.MemberRole; +import dev.vality.orgmanagement.MemberRoleNotFound; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.repository.MemberRepository; +import dev.vality.orgmanager.repository.MemberRoleRepository; +import dev.vality.orgmanager.repository.OrganizationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; + +/** + * Участники организаций и их роли в административном контракте. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminMemberService { + + private final OrganizationRepository organizationRepository; + private final MemberRepository memberRepository; + private final MemberRoleRepository memberRoleRepository; + private final AdminManagementConverter converter; + private final AdminCommonService commonService; + + @Transactional(readOnly = true) + public Member get(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + log.info("Get member: organizationId={}, userId={}", organizationId, userId); + OrganizationEntity organization = commonService.findOrganization(organizationId); + return converter.toMember(findMember(organization, userId), organizationId); + } + + @Transactional(readOnly = true) + public List list(String organizationId) throws OrganizationNotFound { + log.info("List members: organizationId={}", organizationId); + if (!organizationRepository.existsById(organizationId)) { + throw new OrganizationNotFound(); + } + return converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId)); + } + + @Transactional + public Member add(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); + // TODO: email в AddMemberRequest опционален, но колонка member.email объявлена NOT NULL + OrganizationEntity organization = commonService.findOrganization(organizationId); + MemberEntity member = memberRepository.findById(request.getUserId()) + .orElseGet(() -> MemberEntity.builder() + .id(request.getUserId()) + .roles(new HashSet<>()) + .organizations(new HashSet<>()) + .build()); + if (request.isSetEmail()) { + member.setEmail(request.getEmail()); + } + member = memberRepository.save(member); + Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); + members.add(member); + organization.setMembers(members); + organizationRepository.save(organization); + return converter.toMember(member, organizationId); + } + + @Transactional + public void remove(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + log.info("Remove member: organizationId={}, userId={}", organizationId, userId); + OrganizationEntity organization = commonService.findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + + Set retainedRoles = new HashSet<>(); + List removedRoles = new ArrayList<>(); + for (MemberRoleEntity role : collectionOrEmpty(member.getRoles())) { + if (organizationId.equals(role.getOrganizationId()) && role.isActive()) { + role.setActive(false); + removedRoles.add(role); + } else { + retainedRoles.add(role); + } + } + member.setRoles(retainedRoles); + memberRoleRepository.saveAll(removedRoles); + memberRepository.save(member); + + Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); + members.remove(member); + organization.setMembers(members); + organizationRepository.save(organization); + } + + @Transactional + public MemberRole assignRole( + String organizationId, + String userId, + AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + log.info("Assign member role: organizationId={}, userId={}, roleId={}", + organizationId, userId, request.getRoleId()); + OrganizationEntity organization = commonService.findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + MemberRoleEntity role = commonService.toMemberRoleEntity( + organizationId, request.getRoleId(), request.getScope()); + role = memberRoleRepository.save(role); + Set roles = new HashSet<>(collectionOrEmpty(member.getRoles())); + roles.add(role); + member.setRoles(roles); + memberRepository.save(member); + return converter.toMemberRole(role); + } + + @Transactional + public void removeRole(String organizationId, String userId, String memberRoleId) + throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { + log.info("Remove member role: organizationId={}, userId={}, memberRoleId={}", + organizationId, userId, memberRoleId); + // TODO: REST-слой запрещает снимать последнюю активную роль участника в организации + // (OrganizationService.removeMemberRole -> LastRoleException), административный контракт + // это ограничение не повторяет. Нужно либо добавить проверку и исключение в контракт, + // либо явно задокументировать расхождение в admin_management.thrift. + OrganizationEntity organization = commonService.findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + MemberRoleEntity role = collectionOrEmpty(member.getRoles()).stream() + .filter(candidate -> candidate.getId().equals(memberRoleId)) + .filter(candidate -> organizationId.equals(candidate.getOrganizationId())) + .filter(MemberRoleEntity::isActive) + .findFirst() + .orElseThrow(MemberRoleNotFound::new); + role.setActive(false); + Set roles = new HashSet<>(collectionOrEmpty(member.getRoles())); + roles.remove(role); + member.setRoles(roles); + memberRoleRepository.save(role); + memberRepository.save(member); + } + + private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { + return collectionOrEmpty(organization.getMembers()).stream() + .filter(member -> member.getId().equals(userId)) + .findFirst() + .orElseThrow(MemberNotFound::new); + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java new file mode 100644 index 00000000..d28b2da6 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -0,0 +1,176 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.CreateOrganizationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.ListOrganizationsRequest; +import dev.vality.orgmanagement.ListOrganizationsResult; +import dev.vality.orgmanagement.Organization; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.OrganizationRole; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.PartyAlreadyBound; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.entity.StoredOrganizationStatus; +import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.repository.OrganizationRoleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; + +/** + * Организации и их роли в административном контракте. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminOrganizationService { + + static final int DEFAULT_ORGANIZATION_LIMIT = 20; + static final int MAX_ORGANIZATION_LIMIT = 1000; + + private final OrganizationRepository organizationRepository; + private final OrganizationRoleRepository organizationRoleRepository; + private final AdminManagementConverter converter; + private final AdminCommonService commonService; + + @Transactional + public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound { + log.info("Create organization: partyId={}, ownerId={}", request.getPartyId(), request.getOwnerId()); + if (organizationRepository.existsByParty(request.getPartyId())) { + throw new PartyAlreadyBound(); + } + OrganizationEntity entity = OrganizationEntity.builder() + .id(UUID.randomUUID().toString()) + .party(request.getPartyId()) + .owner(request.getOwnerId()) + .name(request.getName()) + .metadata(commonService.toStoredMetadata(request.getMetadata())) + .createdAt(LocalDateTime.now()) + .status(StoredOrganizationStatus.ACTIVE.getValue()) + .members(new HashSet<>()) + .roles(new HashSet<>()) + .build(); + try { + return converter.toOrganization(organizationRepository.saveAndFlush(entity)); + } catch (DataIntegrityViolationException exception) { + throw new PartyAlreadyBound(); + } + } + + @Transactional(readOnly = true) + public Organization get(String organizationId) throws OrganizationNotFound { + log.info("Get organization: organizationId={}", organizationId); + return converter.toOrganization(commonService.findOrganization(organizationId)); + } + + @Transactional(readOnly = true) + public ListOrganizationsResult list(ListOrganizationsRequest request) { + log.info("List organizations: request={}", request); + ListOrganizationsRequest safeRequest = request == null ? new ListOrganizationsRequest() : request; + int limit = organizationLimit(safeRequest); + Pageable pageable = PageRequest.of(0, limit + 1); + List entities = findOrganizations(safeRequest, pageable); + + String continuationToken = null; + if (entities.size() > limit) { + entities = new ArrayList<>(entities.subList(0, limit)); + continuationToken = entities.get(entities.size() - 1).getId(); + } + ListOrganizationsResult result = new ListOrganizationsResult( + entities.stream().map(converter::toOrganization).toList()); + if (continuationToken != null) { + result.setContinuationToken(continuationToken); + } + return result; + } + + @Transactional + public Organization rename(String organizationId, String name) throws OrganizationNotFound { + log.info("Rename organization: organizationId={}, name={}", organizationId, name); + OrganizationEntity organization = commonService.findOrganization(organizationId); + organization.setName(name); + return converter.toOrganization(organizationRepository.save(organization)); + } + + @Transactional + public Organization deactivate(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { + log.info("Deactivate organization: organizationId={}", organizationId); + return changeStatus(organizationId, StoredOrganizationStatus.ACTIVE, StoredOrganizationStatus.DEACTIVATED); + } + + @Transactional + public Organization activate(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { + log.info("Activate organization: organizationId={}", organizationId); + return changeStatus(organizationId, StoredOrganizationStatus.DEACTIVATED, StoredOrganizationStatus.ACTIVE); + } + + @Transactional(readOnly = true) + public OrganizationRole getRole(String organizationId, String roleId) throws OrganizationNotFound { + log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); + commonService.findOrganization(organizationId); + OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) + .orElseThrow(OrganizationNotFound::new); + return converter.toOrganizationRole(role); + } + + @Transactional(readOnly = true) + public List listRoles(String organizationId) throws OrganizationNotFound { + log.info("List organization roles: organizationId={}", organizationId); + OrganizationEntity organization = commonService.findOrganization(organizationId); + return collectionOrEmpty(organization.getRoles()).stream() + .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) + .map(converter::toOrganizationRole) + .toList(); + } + + private List findOrganizations(ListOrganizationsRequest request, Pageable pageable) { + String token = request.getContinuationToken(); + OrganizationStatus status = request.getStatus(); + if (status != null && token != null) { + return organizationRepository.findByStatusAndIdLessThanOrderByIdDesc(status.name(), token, pageable); + } + if (status != null) { + return organizationRepository.findByStatusOrderByIdDesc(status.name(), pageable); + } + if (token != null) { + return organizationRepository.findByIdLessThanOrderByIdDesc(token, pageable); + } + return organizationRepository.findAllByOrderByIdDesc(pageable); + } + + private int organizationLimit(ListOrganizationsRequest request) { + if (!request.isSetLimit() || request.getLimit() <= 0) { + return DEFAULT_ORGANIZATION_LIMIT; + } + return Math.min(request.getLimit(), MAX_ORGANIZATION_LIMIT); + } + + private Organization changeStatus( + String organizationId, + StoredOrganizationStatus expected, + StoredOrganizationStatus target) throws OrganizationNotFound, InvalidOrganizationState { + OrganizationEntity organization = commonService.findOrganization(organizationId); + String current = organization.getStatus(); + if (!expected.matches(current)) { + throw new InvalidOrganizationState( + "Expected organization status " + expected.getValue() + ", but was " + current); + } + organization.setStatus(target.getValue()); + return converter.toOrganization(organizationRepository.save(organization)); + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/InvitationService.java b/src/main/java/dev/vality/orgmanager/service/InvitationService.java index 9848fa3a..904f6a1f 100644 --- a/src/main/java/dev/vality/orgmanager/service/InvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/InvitationService.java @@ -2,6 +2,7 @@ import dev.vality.orgmanager.converter.InvitationConverter; import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.exception.InviteAlreadyAcceptedException; import dev.vality.orgmanager.exception.InviteExpiredException; import dev.vality.orgmanager.exception.InviteRevokedException; @@ -102,7 +103,7 @@ public void checkAndModifyExpiredStatus() { Stream invitationEntity = invitationRepository.findAllPendingStatus(); invitationEntity.forEach(invitation -> { if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { - invitation.setStatus(InvitationStatusName.EXPIRED.getValue()); + invitation.setStatus(StoredInvitationStatus.EXPIRED.getValue()); } }); } @@ -118,17 +119,17 @@ private void validateInvitation(InvitationEntity invitationEntity) { if (invitationEntity.isExpired()) { throw new InviteExpiredException(invitationEntity.getExpiresAt().toString()); } - if (invitationEntity.getStatus().equalsIgnoreCase(InvitationStatusName.REVOKED.getValue())) { + if (StoredInvitationStatus.REVOKED.matches(invitationEntity.getStatus())) { throw new InviteRevokedException(invitationEntity.getRevocationReason()); } - if (invitationEntity.getStatus().equalsIgnoreCase(InvitationStatusName.ACCEPTED.getValue())) { + if (StoredInvitationStatus.ACCEPTED.matches(invitationEntity.getStatus())) { throw new InviteAlreadyAcceptedException(invitationEntity.getAcceptedAt().toString()); } } private boolean isExpiredPendingInvitation(InvitationEntity invitationEntity) { return invitationEntity.getStatus() != null - && invitationEntity.getStatus().equalsIgnoreCase(InvitationStatusName.PENDING.getValue()) + && StoredInvitationStatus.PENDING.matches(invitationEntity.getStatus()) && invitationEntity.isExpired(); } diff --git a/src/main/java/dev/vality/orgmanager/service/OrganizationService.java b/src/main/java/dev/vality/orgmanager/service/OrganizationService.java index f757b9a5..2d5934a4 100644 --- a/src/main/java/dev/vality/orgmanager/service/OrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/OrganizationService.java @@ -297,7 +297,7 @@ private MemberEntity findOrCreateMember(String userId, String userEmail) { private void acceptInvitation(String userId, InvitationEntity invitationEntity) { invitationEntity.setAcceptedAt(LocalDateTime.now()); invitationEntity.setAcceptedMemberId(userId); - invitationEntity.setStatus(InvitationStatusName.ACCEPTED.getValue()); + invitationEntity.setStatus(StoredInvitationStatus.ACCEPTED.getValue()); } @Transactional(readOnly = true) diff --git a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java index 8d17c590..6c18d173 100644 --- a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java +++ b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java @@ -2,6 +2,7 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.entity.StoredOrganizationStatus; import dev.vality.orgmanager.service.model.UserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,7 +35,12 @@ public UserInfo findById(String id) { return new UserInfo( user.orElse(null), Stream.concat(memberOrganizations.stream(), ownedOrganizations.stream()) + .filter(this::isActive) .collect(Collectors.toSet()) ); } + + private boolean isActive(OrganizationEntity organization) { + return StoredOrganizationStatus.ACTIVE.matches(organization.getStatus()); + } } diff --git a/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java b/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java new file mode 100644 index 00000000..3894172d --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java @@ -0,0 +1,34 @@ +package dev.vality.orgmanager.servlet; + +import dev.vality.orgmanagement.AdminManagementSrv; +import dev.vality.woody.thrift.impl.http.THServiceBuilder; +import jakarta.servlet.GenericServlet; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.annotation.WebServlet; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; + +@WebServlet("/admin-management") +@RequiredArgsConstructor +public class AdminManagementServlet extends GenericServlet { + + private final AdminManagementSrv.Iface adminManagement; + private Servlet thriftServlet; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + thriftServlet = new THServiceBuilder() + .build(AdminManagementSrv.Iface.class, adminManagement); + } + + @Override + public void service(ServletRequest request, ServletResponse response) throws ServletException, IOException { + thriftServlet.service(request, response); + } +} diff --git a/src/main/resources/db/migration/V10__add_organization_status.sql b/src/main/resources/db/migration/V10__add_organization_status.sql new file mode 100644 index 00000000..795b1800 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_organization_status.sql @@ -0,0 +1,2 @@ +ALTER TABLE org_manager.organization + ADD COLUMN IF NOT EXISTS status CHARACTER VARYING NOT NULL DEFAULT 'active'; diff --git a/src/test/java/dev/vality/orgmanager/entity/StoredInvitationStatusTest.java b/src/test/java/dev/vality/orgmanager/entity/StoredInvitationStatusTest.java new file mode 100644 index 00000000..7a1756e3 --- /dev/null +++ b/src/test/java/dev/vality/orgmanager/entity/StoredInvitationStatusTest.java @@ -0,0 +1,34 @@ +package dev.vality.orgmanager.entity; + +import dev.vality.swag.organizations.model.InvitationStatusName; +import dev.vality.swag.organizations.model.InviteeContact; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Значения статусов и типа контакта продублированы из swagger-модели, чтобы thrift-слой от неё + * не зависел. Тест ловит расхождение при обновлении swagger-контракта: без него разошедшиеся + * литералы молча испортили бы данные в колонках invitation.status / invitation.invitee_contact_type. + */ +class StoredInvitationStatusTest { + + @Test + void shouldMatchSwaggerInvitationStatusValues() { + assertEquals(InvitationStatusName.PENDING.getValue(), StoredInvitationStatus.PENDING.getValue()); + assertEquals(InvitationStatusName.ACCEPTED.getValue(), StoredInvitationStatus.ACCEPTED.getValue()); + assertEquals(InvitationStatusName.EXPIRED.getValue(), StoredInvitationStatus.EXPIRED.getValue()); + assertEquals(InvitationStatusName.REVOKED.getValue(), StoredInvitationStatus.REVOKED.getValue()); + } + + @Test + void shouldMatchSwaggerInviteeContactTypeValue() { + assertEquals(InviteeContact.TypeEnum.E_MAIL.getValue(), StoredInviteeContactType.EMAIL.getValue()); + } + + @Test + void shouldParseStatusIgnoringCase() { + assertEquals(StoredInvitationStatus.PENDING, StoredInvitationStatus.fromValue("pending")); + assertEquals(StoredInvitationStatus.REVOKED, StoredInvitationStatus.fromValue("Revoked")); + } +} diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java new file mode 100644 index 00000000..ae94510d --- /dev/null +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -0,0 +1,332 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.AddMemberRequest; +import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.CreateInvitationRequest; +import dev.vality.orgmanagement.CreateOrganizationRequest; +import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvitationStatus; +import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListOrganizationsRequest; +import dev.vality.orgmanagement.MemberRoleNotFound; +import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.OrganizationStatus; +import dev.vality.orgmanagement.PartyAlreadyBound; +import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanager.config.properties.InviteTokenProperties; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.entity.InvitationEntity; +import dev.vality.orgmanager.entity.MemberEntity; +import dev.vality.orgmanager.entity.MemberRoleEntity; +import dev.vality.orgmanager.entity.OrganizationEntity; +import dev.vality.orgmanager.repository.InvitationRepository; +import dev.vality.orgmanager.repository.MemberRepository; +import dev.vality.orgmanager.repository.MemberRoleRepository; +import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.repository.OrganizationRoleRepository; +import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import dev.vality.orgmanager.util.JsonCodec; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import tools.jackson.databind.json.JsonMapper; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminManagementServiceTest { + + @Mock + private OrganizationRepository organizationRepository; + @Mock + private MemberRepository memberRepository; + @Mock + private MemberRoleRepository memberRoleRepository; + @Mock + private OrganizationRoleRepository organizationRoleRepository; + @Mock + private InvitationRepository invitationRepository; + @Mock + private InviteTokenProperties inviteTokenProperties; + @Mock + private MailMessageSender mailMessageSender; + + private AdminManagementService service; + + @BeforeEach + void setUp() { + AdminManagementConverter converter = new AdminManagementConverter(); + AdminCommonService commonService = new AdminCommonService( + organizationRepository, + new JsonCodec(JsonMapper.builder().build())); + service = new AdminManagementService( + new AdminOrganizationService( + organizationRepository, + organizationRoleRepository, + converter, + commonService), + new AdminMemberService( + organizationRepository, + memberRepository, + memberRoleRepository, + converter, + commonService), + new AdminInvitationService( + invitationRepository, + inviteTokenProperties, + mailMessageSender, + converter, + commonService)); + } + + @Test + void shouldCreateOrganizationWithRequestedPartyAndOwner() throws Exception { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") + .setMetadata("{\"source\":\"admin\"}"); + when(organizationRepository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var result = service.createOrganization(request); + + assertEquals("party", result.getPartyId()); + assertEquals("owner", result.getOwnerId()); + assertEquals(OrganizationStatus.active, result.getStatus()); + assertEquals(request.getMetadata(), result.getMetadata()); + } + + @Test + void shouldRejectPartyAlreadyBoundToOrganization() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name"); + when(organizationRepository.existsByParty("party")).thenReturn(true); + + assertThrows(PartyAlreadyBound.class, () -> service.createOrganization(request)); + verifyNoInteractions(memberRepository); + } + + @Test + void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { + ListOrganizationsRequest request = new ListOrganizationsRequest().setLimit(2); + List entities = List.of( + organization("c", OrganizationStatus.active), + organization("b", OrganizationStatus.active), + organization("a", OrganizationStatus.active)); + when(organizationRepository.findAllByOrderByIdDesc(any(Pageable.class))).thenReturn(entities); + + var result = service.listOrganizations(request); + + assertEquals(List.of("c", "b"), result.getOrganizations().stream().map(it -> it.getId()).toList()); + assertEquals("b", result.getContinuationToken()); + } + + @Test + void shouldValidateOrganizationStatusTransitions() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var deactivated = service.deactivateOrganization("org"); + + assertEquals(OrganizationStatus.deactivated, deactivated.getStatus()); + assertThrows(InvalidOrganizationState.class, () -> service.deactivateOrganization("org")); + assertEquals(OrganizationStatus.active, service.activateOrganization("org").getStatus()); + } + + @Test + void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var member = service.addMember("org", new AddMemberRequest("user").setEmail("user@example.com")); + var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); + service.removeMemberRole("org", "user", role.getId()); + + assertEquals("user@example.com", member.getEmail()); + assertTrue(service.getMember("org", "user").getRoles().isEmpty()); + } + + @Test + void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { + MemberRoleEntity role = MemberRoleEntity.builder() + .id("role") + .organizationId("another-org") + .roleId("manager") + .active(true) + .build(); + MemberEntity member = MemberEntity.builder() + .id("user") + .roles(new HashSet<>(Set.of(role))) + .build(); + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(member))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + + assertThrows(MemberRoleNotFound.class, + () -> service.removeMemberRole("org", "user", "role")); + } + + @Test + void shouldUseEffectiveInvitationStatusForListingAndRevocation() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + InvitationEntity expired = invitation("expired", LocalDateTime.now().minusMinutes(1)); + InvitationEntity pending = invitation("pending", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(invitationRepository.findByOrganizationId("org")).thenReturn(List.of(expired, pending)); + when(invitationRepository.findByIdAndOrganizationId("expired", "org")).thenReturn(Optional.of(expired)); + when(invitationRepository.findByIdAndOrganizationId("pending", "org")).thenReturn(Optional.of(pending)); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setStatus(InvitationStatus.expired)); + + assertEquals(List.of("expired"), result.stream().map(it -> it.getId()).toList()); + assertThrows(InvalidOrganizationState.class, + () -> service.revokeInvitation("org", "expired", new RevokeInvitationRequest("obsolete"))); + service.revokeInvitation("org", "pending", new RevokeInvitationRequest("obsolete")); + assertEquals("Revoked", pending.getStatus()); + assertEquals("obsolete", pending.getRevocationReason()); + verify(invitationRepository).save(pending); + } + + @Test + void shouldCreateInvitationAndSendMail() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(inviteTokenProperties.getLifeTimeInDays()).thenReturn(7L); + when(invitationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + CreateInvitationRequest request = new CreateInvitationRequest("user@example.com", new ArrayList<>()); + + var result = service.createInvitation("org", request); + + assertEquals(InvitationStatus.pending, result.getStatus()); + assertEquals("user@example.com", result.getEmail()); + verify(mailMessageSender).send(any(String.class), eq("user@example.com")); + } + + @Test + void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + // REST-слой пишет метаданные через JsonCodec, поэтому при их отсутствии в базе лежит "null" + organization.setMetadata("null"); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + + assertFalse(service.getOrganization("org").isSetMetadata()); + } + + @Test + void shouldRejectMetadataThatRestWouldFailToRead() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") + .setMetadata("not a json"); + + assertThrows(IllegalArgumentException.class, () -> service.createOrganization(request)); + verifyNoInteractions(memberRepository); + } + + @Test + void shouldListMembersIncludingOnesWithoutRoles() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberListWithRoles("org")).thenReturn(List.of( + memberRow("user-1", "user-1@example.com", "role-1", "manager"), + memberRow("user-1", "user-1@example.com", "role-2", "accountant"), + memberRow("user-2", "user-2@example.com", null, null))); + + var members = service.listMembers("org"); + + assertEquals(List.of("user-1", "user-2"), members.stream().map(it -> it.getId()).toList()); + assertEquals(List.of("manager", "accountant"), + members.get(0).getRoles().stream().map(it -> it.getRoleId()).toList()); + assertTrue(members.get(1).getRoles().isEmpty()); + assertEquals("user-2@example.com", members.get(1).getEmail()); + } + + @Test + void shouldRejectListingMembersOfUnknownOrganization() { + when(organizationRepository.existsById("org")).thenReturn(false); + + assertThrows(OrganizationNotFound.class, () -> service.listMembers("org")); + verifyNoInteractions(memberRepository); + } + + private MemberWithRoleDto memberRow(String memberId, String email, String roleId, String role) { + return new MemberWithRoleDto() { + @Override + public String getId() { + return memberId; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getMemberRoleId() { + return roleId; + } + + @Override + public String getOrganizationId() { + return "org"; + } + + @Override + public String getRoleId() { + return role; + } + + @Override + public String getScopeId() { + return null; + } + + @Override + public String getResourceId() { + return null; + } + }; + } + + private OrganizationEntity organization(String id, OrganizationStatus status) { + return OrganizationEntity.builder() + .id(id) + .party("party-" + id) + .owner("owner-" + id) + .name("name-" + id) + .createdAt(LocalDateTime.now()) + .status(status.name()) + .members(new HashSet<>()) + .roles(new HashSet<>()) + .build(); + } + + private InvitationEntity invitation(String id, LocalDateTime expiresAt) { + return InvitationEntity.builder() + .id(id) + .organizationId("org") + .createdAt(LocalDateTime.now()) + .expiresAt(expiresAt) + .inviteeContactEmail("user@example.com") + .inviteeRoles(new HashSet<>()) + .status("Pending") + .build(); + } +} diff --git a/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java b/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java index 3fe8f5cb..be52e725 100644 --- a/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java +++ b/src/test/java/dev/vality/orgmanager/service/UserServiceImplTest.java @@ -124,4 +124,19 @@ void findMemberUserWithSameMemberAndOwnedOrganizations() { assertEquals(1, userInfo.getOrganizations().size()); assertEquals(organization.getId(), userInfo.getOrganizations().iterator().next().getId()); } + + @Test + void doesNotExposeDeactivatedOrganizationInUserContext() { + String memberId = TestObjectFactory.randomString(); + MemberEntity member = TestObjectFactory.testMemberEntity(memberId); + OrganizationEntity organization = TestObjectFactory.buildOrganization(member); + organization.setStatus("deactivated"); + memberRepository.save(member); + organizationRepository.save(organization); + + UserInfo userInfo = userService.findById(memberId); + + assertEquals(memberId, userInfo.getMember().getId()); + assertTrue(userInfo.getOrganizations().isEmpty()); + } }