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