From 2f4fe361dae204680e562ae2bfb209d61bdab528 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Mon, 24 Aug 2026 15:00:12 +0400 Subject: [PATCH 01/19] Bump Spring Boot 4 --- .github/workflows/build.yml | 2 +- .github/workflows/deploy.yml | 2 +- pom.xml | 68 ++++++++---------- .../orgmanager/OrgManagerApplication.java | 2 +- .../vality/orgmanager/config/AppConfig.java | 15 ++-- .../config/ConfigResolverConfig.java | 6 +- .../vality/orgmanager/config/OtelConfig.java | 70 ------------------- .../config/TomcatWebServerCustomizer.java | 4 +- .../config/properties/OtelProperties.java | 17 ----- .../converter/InvitationConverter.java | 8 +-- .../converter/OrganizationConverter.java | 8 +-- .../pagination/OffsetBasedPageRequest.java | 2 + .../repository/MemberRepository.java | 6 +- .../repository/OrganizationRepository.java | 14 ++-- .../orgmanager/service/KeycloakService.java | 6 +- .../vality/orgmanager/util/JsonMapper.java | 27 ------- src/main/resources/application.yml | 5 -- .../controller/AbstractControllerTest.java | 11 +-- .../converter/InvitationConverterTest.java | 18 +++-- .../converter/OrganizationConverterTest.java | 7 +- .../repository/AbstractRepositoryTest.java | 4 +- .../service/BouncerServiceImplTest.java | 18 +++-- .../ResourceAccessServiceImplTest.java | 8 +-- src/test/resources/wiremock.properties | 1 - 24 files changed, 106 insertions(+), 223 deletions(-) delete mode 100644 src/main/java/dev/vality/orgmanager/config/OtelConfig.java delete mode 100644 src/main/java/dev/vality/orgmanager/config/properties/OtelProperties.java delete mode 100644 src/main/java/dev/vality/orgmanager/util/JsonMapper.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dbb1a95a..8ffed7c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,4 +7,4 @@ on: jobs: build: - uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v3 + uses: valitydev/java-workflow/.github/workflows/maven-service-build.yml@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f366b27..93aebc69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,7 @@ on: jobs: build-and-deploy: - uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v3 + uses: valitydev/java-workflow/.github/workflows/maven-service-deploy.yml@v4 secrets: github-token: ${{ secrets.GITHUB_TOKEN }} mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }} diff --git a/pom.xml b/pom.xml index e8ed8868..cf8165f1 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ dev.vality service-parent-pom - 3.1.8 + 4.0.0 org-manager @@ -16,8 +16,7 @@ org-manager - 21 - 21 + 25 UTF-8 UTF-8 8022 @@ -27,9 +26,9 @@ 17.0.0 4.34.0 1.38-8c9b968 - 2.32.0 1.685-5c25c2e - 3.0.1 + 5.0.0 + 3.0.0 @@ -37,11 +36,16 @@ dev.vality.woody woody-thrift + ${woody.version} + + + dev.vality.woody + woody-api + ${woody.version} dev.vality shared-resources - ${shared-resources.version} dev.vality @@ -82,7 +86,7 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-webmvc org.springframework.boot @@ -98,7 +102,7 @@ org.springframework.boot - spring-boot-starter-oauth2-resource-server + spring-boot-starter-security-oauth2-resource-server net.javacrumbs.shedlock @@ -118,8 +122,8 @@ org.bouncycastle - bcprov-jdk15on - 1.70 + bcprov-jdk18on + 1.84 @@ -140,14 +144,15 @@ org.projectlombok lombok + provided org.postgresql postgresql - org.flywaydb - flyway-core + org.springframework.boot + spring-boot-starter-flyway org.flywaydb @@ -161,18 +166,11 @@ org.openapitools jackson-databind-nullable - 0.2.2 + 0.2.10 org.yaml snakeyaml - 2.0 - - - - io.opentelemetry - opentelemetry-semconv - 1.29.0-alpha @@ -180,23 +178,16 @@ org.springframework.boot spring-boot-starter-test test - - - org.junit.vintage - junit-vintage-engine - - - org.springframework.cloud - spring-cloud-contract-wiremock - 4.1.5 + org.springframework.boot + spring-boot-starter-webmvc-test test - com.github.tomakehurst - wiremock-jre8-standalone - ${wiremock-jre8-standalone.version} + org.wiremock.integrations + wiremock-spring-boot + 4.0.9 test @@ -207,8 +198,7 @@ org.testcontainers - postgresql - 1.19.1 + testcontainers-postgresql test @@ -219,8 +209,7 @@ org.testcontainers - junit-jupiter - 1.19.1 + testcontainers-junit-jupiter test @@ -240,6 +229,7 @@ true Dockerfile + opentelemetry-javaagent.jar @@ -281,15 +271,13 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.15.0 - 21 - 21 org.projectlombok lombok - 1.18.36 + ${lombok.version} diff --git a/src/main/java/dev/vality/orgmanager/OrgManagerApplication.java b/src/main/java/dev/vality/orgmanager/OrgManagerApplication.java index 1338b8b7..e74f6f6c 100644 --- a/src/main/java/dev/vality/orgmanager/OrgManagerApplication.java +++ b/src/main/java/dev/vality/orgmanager/OrgManagerApplication.java @@ -3,7 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.boot.web.server.servlet.context.ServletComponentScan; @ServletComponentScan @ConfigurationPropertiesScan diff --git a/src/main/java/dev/vality/orgmanager/config/AppConfig.java b/src/main/java/dev/vality/orgmanager/config/AppConfig.java index 59e468b3..5a5f0c39 100644 --- a/src/main/java/dev/vality/orgmanager/config/AppConfig.java +++ b/src/main/java/dev/vality/orgmanager/config/AppConfig.java @@ -1,20 +1,23 @@ package dev.vality.orgmanager.config; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.vality.orgmanager.controller.converter.InvitationStatusConverter; -import org.openapitools.jackson.nullable.JsonNullableModule; +import org.openapitools.jackson.nullable.JsonNullableJackson3Module; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.format.support.FormattingConversionService; @Configuration public class AppConfig { - @Autowired - public void objectMapper(ObjectMapper mapper) { - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - mapper.registerModule(new JsonNullableModule()); + @Bean + public JsonMapperBuilderCustomizer jsonMapperCustomizer() { + return builder -> builder + .changeDefaultPropertyInclusion(inclusion -> + inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) + .addModule(new JsonNullableJackson3Module()); } @Autowired diff --git a/src/main/java/dev/vality/orgmanager/config/ConfigResolverConfig.java b/src/main/java/dev/vality/orgmanager/config/ConfigResolverConfig.java index 7152cfd0..230e17a9 100644 --- a/src/main/java/dev/vality/orgmanager/config/ConfigResolverConfig.java +++ b/src/main/java/dev/vality/orgmanager/config/ConfigResolverConfig.java @@ -13,13 +13,13 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.X509EncodedKeySpec; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.Base64; import java.util.List; import java.util.stream.Collectors; @@ -65,7 +65,7 @@ private RSAPublicKey readPublicKey(String publicKey) { private String readKeyFromFile(String filePath) { try { - List strings = Files.readAllLines(Paths.get(filePath)); + List strings = Files.readAllLines(Path.of(filePath)); strings.remove(strings.size() - 1); strings.remove(0); diff --git a/src/main/java/dev/vality/orgmanager/config/OtelConfig.java b/src/main/java/dev/vality/orgmanager/config/OtelConfig.java deleted file mode 100644 index 630c3aa8..00000000 --- a/src/main/java/dev/vality/orgmanager/config/OtelConfig.java +++ /dev/null @@ -1,70 +0,0 @@ -package dev.vality.orgmanager.config; - -import dev.vality.orgmanager.config.properties.OtelProperties; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.common.Attributes; -import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; -import io.opentelemetry.context.propagation.ContextPropagators; -import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; -import io.opentelemetry.sdk.OpenTelemetrySdk; -import io.opentelemetry.sdk.resources.Resource; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; -import io.opentelemetry.sdk.trace.samplers.Sampler; -import io.opentelemetry.semconv.resource.attributes.ResourceAttributes; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.time.Duration; - -@Slf4j -@Configuration -@ConditionalOnProperty(value = "otel.enabled", havingValue = "true", matchIfMissing = true) -@RequiredArgsConstructor -public class OtelConfig { - - private final OtelProperties otelProperties; - - @Value("${spring.application.name}") - private String applicationName; - - @Bean - public OpenTelemetry openTelemetryConfig() { - var resource = Resource.getDefault() - .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, applicationName))); - var sdkTracerProvider = SdkTracerProvider.builder() - .addSpanProcessor(BatchSpanProcessor.builder(OtlpHttpSpanExporter.builder() - .setEndpoint(otelProperties.getResource()) - .setTimeout(Duration.ofMillis(otelProperties.getTimeout())) - .build()) - .build()) - .setSampler(Sampler.alwaysOn()) - .setResource(resource) - .build(); - var openTelemetrySdk = OpenTelemetrySdk.builder() - .setTracerProvider(sdkTracerProvider) - .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) - .build(); - registerGlobalOpenTelemetry(openTelemetrySdk); - return openTelemetrySdk; - } - - private static void registerGlobalOpenTelemetry(OpenTelemetry openTelemetry) { - try { - GlobalOpenTelemetry.set(openTelemetry); - } catch (Throwable ex) { - log.warn("Please initialize the ObservabilitySdk before starting the application", ex); - GlobalOpenTelemetry.resetForTest(); - try { - GlobalOpenTelemetry.set(openTelemetry); - } catch (Throwable ex1) { - log.warn("Unable to set GlobalOpenTelemetry", ex1); - } - } - } -} \ No newline at end of file diff --git a/src/main/java/dev/vality/orgmanager/config/TomcatWebServerCustomizer.java b/src/main/java/dev/vality/orgmanager/config/TomcatWebServerCustomizer.java index 6ecb1fa2..ee654ec9 100644 --- a/src/main/java/dev/vality/orgmanager/config/TomcatWebServerCustomizer.java +++ b/src/main/java/dev/vality/orgmanager/config/TomcatWebServerCustomizer.java @@ -2,7 +2,7 @@ import org.apache.catalina.connector.Connector; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -19,6 +19,6 @@ public void customize(TomcatServletWebServerFactory factory) { Connector connector = new Connector(); connector.setPort(restPort); - factory.addAdditionalTomcatConnectors(connector); + factory.addAdditionalConnectors(connector); } } diff --git a/src/main/java/dev/vality/orgmanager/config/properties/OtelProperties.java b/src/main/java/dev/vality/orgmanager/config/properties/OtelProperties.java deleted file mode 100644 index ff18b2d3..00000000 --- a/src/main/java/dev/vality/orgmanager/config/properties/OtelProperties.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.vality.orgmanager.config.properties; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "otel") -public class OtelProperties { - - private String resource; - private Long timeout; - -} diff --git a/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java b/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java index b9242262..d91232e0 100644 --- a/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/InvitationConverter.java @@ -2,7 +2,7 @@ import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.entity.InvitationEntity; -import dev.vality.orgmanager.util.JsonMapper; +import dev.vality.orgmanager.util.JsonCodec; import dev.vality.swag.organizations.model.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,7 +21,7 @@ @RequiredArgsConstructor public class InvitationConverter { - private final JsonMapper jsonMapper; + private final JsonCodec jsonCodec; private final MemberRoleConverter memberRoleConverter; private final InviteTokenProperties inviteTokenProperties; @@ -48,7 +48,7 @@ public InvitationEntity toEntity(InvitationRequest invitation, String orgId) { .stream() .map(role -> memberRoleConverter.toEntity(role, orgId)) .collect(toSet())) - .metadata(jsonMapper.toJson(invitation.getMetadata())) + .metadata(jsonCodec.toJson(invitation.getMetadata())) .status(InvitationStatusName.PENDING.getValue()) .acceptToken(UUID.randomUUID().toString()) // TODO [a.romanov]: token .build(); @@ -81,7 +81,7 @@ public Invitation toDomain(InvitationEntity entity) { invitation.id(entity.getId()) .createdAt(OffsetDateTime.of(entity.getCreatedAt(), ZoneOffset.UTC)) .expiresAt(OffsetDateTime.of(entity.getExpiresAt(), ZoneOffset.UTC)) - .metadata(entity.getMetadata() != null ? jsonMapper.toMap(entity.getMetadata()) : null) + .metadata(entity.getMetadata() != null ? jsonCodec.toMap(entity.getMetadata()) : null) .invitee(new Invitee() .contact(new InviteeContact() .type(InviteeContact.TypeEnum.fromValue(entity.getInviteeContactType())) diff --git a/src/main/java/dev/vality/orgmanager/converter/OrganizationConverter.java b/src/main/java/dev/vality/orgmanager/converter/OrganizationConverter.java index 80951cb0..ad504520 100644 --- a/src/main/java/dev/vality/orgmanager/converter/OrganizationConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/OrganizationConverter.java @@ -1,7 +1,7 @@ package dev.vality.orgmanager.converter; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.util.JsonMapper; +import dev.vality.orgmanager.util.JsonCodec; import dev.vality.swag.organizations.model.Organization; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class OrganizationConverter { - private final JsonMapper jsonMapper; + private final JsonCodec jsonCodec; public OrganizationEntity toEntity(Organization organization, String ownerId) { String orgId = UUID.randomUUID().toString(); @@ -26,7 +26,7 @@ public OrganizationEntity toEntity(Organization organization, String ownerId) { .name(organization.getName()) .owner(ownerId) .party(partyId) - .metadata(jsonMapper.toJson(organization.getMetadata())) + .metadata(jsonCodec.toJson(organization.getMetadata())) .build(); } @@ -37,7 +37,7 @@ public Organization toDomain(OrganizationEntity entity) { .name(entity.getName()) .owner(entity.getOwner()) .party(entity.getParty()) - .metadata(entity.getMetadata() != null ? jsonMapper.toMap(entity.getMetadata()) : null); + .metadata(entity.getMetadata() != null ? jsonCodec.toMap(entity.getMetadata()) : null); } } diff --git a/src/main/java/dev/vality/orgmanager/pagination/OffsetBasedPageRequest.java b/src/main/java/dev/vality/orgmanager/pagination/OffsetBasedPageRequest.java index 44d8ca62..7482b04c 100644 --- a/src/main/java/dev/vality/orgmanager/pagination/OffsetBasedPageRequest.java +++ b/src/main/java/dev/vality/orgmanager/pagination/OffsetBasedPageRequest.java @@ -4,11 +4,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import java.io.Serial; import java.io.Serializable; @EqualsAndHashCode public class OffsetBasedPageRequest implements Pageable, Serializable { + @Serial private static final long serialVersionUID = -35826477329623545L; private int limit; diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index a064e034..7435e685 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -3,7 +3,7 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.stereotype.Repository; import java.util.List; @@ -12,7 +12,7 @@ public interface MemberRepository extends JpaRepository { - @Query(value = "SELECT m.id, " + + @NativeQuery("SELECT m.id, " + " m.email, " + " mr.id as memberRoleId, " + " mr.organization_id as organizationId, " + @@ -29,7 +29,7 @@ public interface MemberRepository extends JpaRepository { " AND mr.active = 'true' " + " AND mr.id = mtmr.member_role_id " + " AND mr.organization_id = mto.organization_id " + - " AND m.id = mtmr.member_id ", nativeQuery = true) + " AND m.id = mtmr.member_id ") List getOrgMemberList(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 bf18329d..a5581c2c 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -2,7 +2,7 @@ import dev.vality.orgmanager.entity.OrganizationEntity; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,8 +11,7 @@ @Repository public interface OrganizationRepository extends JpaRepository { - @Query(value = - " SELECT * FROM org_manager.organization AS o " + + @NativeQuery(" SELECT * FROM org_manager.organization AS o " + " WHERE o.id IN " + " ( " + " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + @@ -20,12 +19,10 @@ public interface OrganizationRepository extends JpaRepository findAllByMember(String userId); - @Query(value = - " SELECT * FROM org_manager.organization AS o " + + @NativeQuery(" SELECT * FROM org_manager.organization AS o " + " WHERE o.id IN " + " ( " + " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + @@ -34,8 +31,7 @@ public interface OrganizationRepository extends JpaRepository findAllByMember(String userId, String continuationId); Set findAllByOwner(String owner); diff --git a/src/main/java/dev/vality/orgmanager/service/KeycloakService.java b/src/main/java/dev/vality/orgmanager/service/KeycloakService.java index 23fdc5b3..218a93da 100644 --- a/src/main/java/dev/vality/orgmanager/service/KeycloakService.java +++ b/src/main/java/dev/vality/orgmanager/service/KeycloakService.java @@ -1,12 +1,12 @@ package dev.vality.orgmanager.service; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.keycloak.representations.AccessToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; +import tools.jackson.databind.json.JsonMapper; import java.time.Instant; import java.util.HashMap; @@ -16,14 +16,14 @@ @RequiredArgsConstructor public class KeycloakService { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; public AccessToken getAccessToken() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { throw new IllegalStateException("Jwt principal is required"); } - return objectMapper.convertValue(normalizedClaims(jwt.getClaims()), AccessToken.class); + return jsonMapper.convertValue(normalizedClaims(jwt.getClaims()), AccessToken.class); } private Map normalizedClaims(Map claims) { diff --git a/src/main/java/dev/vality/orgmanager/util/JsonMapper.java b/src/main/java/dev/vality/orgmanager/util/JsonMapper.java deleted file mode 100644 index 27a3ec67..00000000 --- a/src/main/java/dev/vality/orgmanager/util/JsonMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.vality.orgmanager.util; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import org.springframework.stereotype.Service; - -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class JsonMapper { - - private final ObjectMapper objectMapper; - - @SneakyThrows(JsonProcessingException.class) - public String toJson(Object data) { - return objectMapper.writeValueAsString(data); - } - - @SuppressWarnings("rawtypes") - @SneakyThrows(JsonProcessingException.class) - public Map toMap(String json) { - return objectMapper.readValue(json, Map.class); - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a318fdad..c7941c52 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -92,8 +92,3 @@ bouncer: access-check: enabled: false - -otel: - resource: http://localhost:4318/v1/traces - timeout: 60000 - enabled: false diff --git a/src/test/java/dev/vality/orgmanager/controller/AbstractControllerTest.java b/src/test/java/dev/vality/orgmanager/controller/AbstractControllerTest.java index 8a96c7d2..f39fc712 100644 --- a/src/test/java/dev/vality/orgmanager/controller/AbstractControllerTest.java +++ b/src/test/java/dev/vality/orgmanager/controller/AbstractControllerTest.java @@ -1,20 +1,21 @@ package dev.vality.orgmanager.controller; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.vality.orgmanager.repository.AbstractRepositoryTest; import dev.vality.orgmanager.service.ResourceAccessService; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.annotation.Import; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; +import org.wiremock.spring.ConfigureWireMock; +import org.wiremock.spring.EnableWireMock; +import tools.jackson.databind.json.JsonMapper; @Import(KeycloakTestConfig.class) @AutoConfigureMockMvc -@AutoConfigureWireMock(port = 0) +@EnableWireMock(@ConfigureWireMock(port = 0, baseUrlProperties = "wiremock.server.baseUrl")) @TestPropertySource( locations = "classpath:wiremock.properties", properties = "auth.enabled=true" @@ -37,7 +38,7 @@ public void setUp() throws Exception { protected MockMvc mockMvc; @Autowired - protected ObjectMapper objectMapper; + protected JsonMapper objectMapper; protected String generateJwt(long iat, long exp, String... roles) { return keycloakOpenIdStub.generateJwt(iat, exp, roles); diff --git a/src/test/java/dev/vality/orgmanager/converter/InvitationConverterTest.java b/src/test/java/dev/vality/orgmanager/converter/InvitationConverterTest.java index 6f45c357..df6e3ffa 100644 --- a/src/test/java/dev/vality/orgmanager/converter/InvitationConverterTest.java +++ b/src/test/java/dev/vality/orgmanager/converter/InvitationConverterTest.java @@ -1,13 +1,19 @@ package dev.vality.orgmanager.converter; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.entity.InvitationEntity; import dev.vality.orgmanager.entity.MemberRoleEntity; -import dev.vality.orgmanager.util.JsonMapper; -import dev.vality.swag.organizations.model.*; +import dev.vality.orgmanager.util.JsonCodec; +import dev.vality.swag.organizations.model.Invitation; +import dev.vality.swag.organizations.model.InvitationPending; +import dev.vality.swag.organizations.model.InvitationRequest; +import dev.vality.swag.organizations.model.InvitationStatusName; +import dev.vality.swag.organizations.model.Invitee; +import dev.vality.swag.organizations.model.InviteeContact; +import dev.vality.swag.organizations.model.MemberRole; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -36,7 +42,7 @@ public void setUp() { when(inviteTokenProperties.getLifeTimeInDays()).thenReturn(30L); converter = new InvitationConverter( - new JsonMapper(new ObjectMapper()), + new JsonCodec(new JsonMapper()), memberRoleConverter, inviteTokenProperties ); @@ -63,7 +69,9 @@ void shouldConvertToEntity() throws Exception { assertThat(entity.getInviteeContactType()).isEqualTo(invitation.getInvitee().getContact().getType().getValue()); assertThat(entity.getOrganizationId()).isEqualTo(orgId); assertThat(entity.getStatus()).isEqualTo(InvitationStatusName.PENDING.getValue()); - assertThat(entity.getMetadata()).isEqualTo(new ObjectMapper().writeValueAsString(invitation.getMetadata())); + String expectedMetadata = new JsonMapper() + .writeValueAsString(invitation.getMetadata()); + assertThat(entity.getMetadata()).isEqualTo(expectedMetadata); } @Test diff --git a/src/test/java/dev/vality/orgmanager/converter/OrganizationConverterTest.java b/src/test/java/dev/vality/orgmanager/converter/OrganizationConverterTest.java index 4b13be14..b693a468 100644 --- a/src/test/java/dev/vality/orgmanager/converter/OrganizationConverterTest.java +++ b/src/test/java/dev/vality/orgmanager/converter/OrganizationConverterTest.java @@ -1,12 +1,12 @@ package dev.vality.orgmanager.converter; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.vality.orgmanager.TestObjectFactory; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.util.JsonMapper; +import dev.vality.orgmanager.util.JsonCodec; import dev.vality.swag.organizations.model.Organization; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import java.util.Map; @@ -20,8 +20,7 @@ class OrganizationConverterTest { @BeforeEach public void setUp() { converter = new OrganizationConverter( - new JsonMapper( - new ObjectMapper())); + new JsonCodec(new JsonMapper())); } @Test diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index 8e8ea355..c5a3e1c3 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -11,7 +11,7 @@ import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; -import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; @ActiveProfiles("test") @@ -69,7 +69,7 @@ static void beforeAll() { } @SuppressWarnings("rawtypes") - static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName + static PostgreSQLContainer postgres = new PostgreSQLContainer(DockerImageName .parse(POSTGRESQL_IMAGE_NAME) .withTag(POSTGRESQL_VERSION)); diff --git a/src/test/java/dev/vality/orgmanager/service/BouncerServiceImplTest.java b/src/test/java/dev/vality/orgmanager/service/BouncerServiceImplTest.java index 4a89c489..3ca63a8a 100644 --- a/src/test/java/dev/vality/orgmanager/service/BouncerServiceImplTest.java +++ b/src/test/java/dev/vality/orgmanager/service/BouncerServiceImplTest.java @@ -1,6 +1,12 @@ package dev.vality.orgmanager.service; -import dev.vality.bouncer.decisions.*; +import dev.vality.bouncer.decisions.ArbiterSrv; +import dev.vality.bouncer.decisions.Context; +import dev.vality.bouncer.decisions.Judgement; +import dev.vality.bouncer.decisions.Resolution; +import dev.vality.bouncer.decisions.ResolutionAllowed; +import dev.vality.bouncer.decisions.ResolutionRestricted; +import dev.vality.bouncer.decisions.RulesetNotFound; import dev.vality.orgmanagement.UserNotFound; import dev.vality.orgmanager.TestObjectFactory; import dev.vality.orgmanager.config.properties.BouncerProperties; @@ -11,16 +17,18 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.mockito.junit.jupiter.MockitoExtension; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.*; +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.anyString; import static org.mockito.Mockito.when; -@ExtendWith(SpringExtension.class) +@ExtendWith(MockitoExtension.class) class BouncerServiceImplTest { @Mock @@ -89,4 +97,4 @@ void havePrivilegesWithAllowedResolution() throws TException { assertTrue(result); } -} \ No newline at end of file +} diff --git a/src/test/java/dev/vality/orgmanager/service/ResourceAccessServiceImplTest.java b/src/test/java/dev/vality/orgmanager/service/ResourceAccessServiceImplTest.java index 28a09f66..3f5418ba 100644 --- a/src/test/java/dev/vality/orgmanager/service/ResourceAccessServiceImplTest.java +++ b/src/test/java/dev/vality/orgmanager/service/ResourceAccessServiceImplTest.java @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.mockito.junit.jupiter.MockitoExtension; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; @@ -21,7 +21,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@ExtendWith(SpringExtension.class) +@ExtendWith(MockitoExtension.class) class ResourceAccessServiceImplTest { private AccessProperties accessProperties; @@ -75,7 +75,6 @@ void checkJoinOrgRightsWithNotExistOrg() { ResourceDto resource = ResourceDto.builder() .invitationToken(TestObjectFactory.randomString()) .build(); - when(bouncerService.havePrivileges(any(BouncerContextDto.class))).thenReturn(true); when(organizationService.getOrgIdByInvitationToken(resource.getInvitationToken())) .thenThrow(new ResourceNotFoundException()); @@ -120,7 +119,6 @@ void checkMemberRoleRightsWithNotExistRole() { ResourceDto resource = ResourceDto.builder() .memberRoleId(TestObjectFactory.randomString()) .build(); - when(bouncerService.havePrivileges(any(BouncerContextDto.class))).thenReturn(true); when(memberRoleService.findById(resource.getMemberRoleId())) .thenThrow(new ResourceNotFoundException()); @@ -154,4 +152,4 @@ void checkInvitationRights() { assertDoesNotThrow(() -> resourceAccessService.checkRights(resource)); } -} \ No newline at end of file +} diff --git a/src/test/resources/wiremock.properties b/src/test/resources/wiremock.properties index d56ce677..83d00cf4 100644 --- a/src/test/resources/wiremock.properties +++ b/src/test/resources/wiremock.properties @@ -1,2 +1 @@ -wiremock.server.baseUrl=http://localhost:${wiremock.server.port} keycloak.auth-server-url=${wiremock.server.baseUrl}/auth From 753e7c37bf3fb207b043dd1deb49dd9b87f99071 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Mon, 24 Aug 2026 15:09:05 +0400 Subject: [PATCH 02/19] fix --- .../dev/vality/orgmanager/util/JsonCodec.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/dev/vality/orgmanager/util/JsonCodec.java diff --git a/src/main/java/dev/vality/orgmanager/util/JsonCodec.java b/src/main/java/dev/vality/orgmanager/util/JsonCodec.java new file mode 100644 index 00000000..b2a6a270 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/util/JsonCodec.java @@ -0,0 +1,27 @@ +package dev.vality.orgmanager.util; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class JsonCodec { + + private final JsonMapper jsonMapper; + + @SneakyThrows(JacksonException.class) + public String toJson(Object data) { + return jsonMapper.writeValueAsString(data); + } + + @SuppressWarnings("rawtypes") + @SneakyThrows(JacksonException.class) + public Map toMap(String json) { + return jsonMapper.readValue(json, Map.class); + } +} From 819c9ff1376f6083c653e588686954e1ab738a9e Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Tue, 25 Aug 2026 12:52:33 +0400 Subject: [PATCH 03/19] impl --- pom.xml | 2 +- .../converter/AdminManagementConverter.java | 148 +++++++ .../orgmanager/entity/OrganizationEntity.java | 2 + .../repository/OrganizationRepository.java | 14 + .../service/AdminManagementService.java | 393 ++++++++++++++++++ .../orgmanager/service/UserServiceImpl.java | 6 + .../servlet/AdminManagementServlet.java | 34 ++ .../V10__add_organization_status.sql | 2 + .../service/AdminManagementServiceTest.java | 231 ++++++++++ .../service/UserServiceImplTest.java | 15 + 10 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminManagementService.java create mode 100644 src/main/java/dev/vality/orgmanager/servlet/AdminManagementServlet.java create mode 100644 src/main/resources/db/migration/V10__add_organization_status.sql create mode 100644 src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java diff --git a/pom.xml b/pom.xml index cf8165f1..e12c770b 100644 --- a/pom.xml +++ b/pom.xml @@ -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..476e3989 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -0,0 +1,148 @@ +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 org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +@Component +public class AdminManagementConverter { + + public Organization toOrganization(OrganizationEntity entity) { + Organization organization = new Organization( + entity.getId(), + entity.getParty(), + entity.getOwner(), + entity.getName(), + formatDateTime(entity.getCreatedAt()), + toOrganizationStatus(entity.getStatus())); + if (entity.getMetadata() != null) { + organization.setMetadata(entity.getMetadata()); + } + 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; + } + + public MemberRole toMemberRole(MemberRoleEntity entity) { + MemberRole role = new MemberRole(entity.getId(), entity.getRoleId()); + if (entity.getScopeId() != null) { + role.setScope(toRoleScope(entity)); + } + return role; + } + + public RoleAssignment toRoleAssignment(MemberRoleEntity entity) { + RoleAssignment assignment = new RoleAssignment(entity.getRoleId()); + if (entity.getScopeId() != null) { + assignment.setScope(toRoleScope(entity)); + } + 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)); + if (entity.getMetadata() != null) { + invitation.setMetadata(entity.getMetadata()); + } + 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(MemberRoleEntity entity) { + RoleScope scope = new RoleScope(entity.getScopeId()); + if (entity.getResourceId() != null) { + scope.setResourceId(entity.getResourceId()); + } + 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 InvitationStatus.valueOf(status.toLowerCase(Locale.ROOT)); + } + + 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/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/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/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java new file mode 100644 index 00000000..f014a7a7 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -0,0 +1,393 @@ +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.InvitationStatus; +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.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.entity.OrganizationRoleEntity; +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.swag.organizations.model.InvitationStatusName; +import dev.vality.swag.organizations.model.InviteeContact; +import lombok.RequiredArgsConstructor; +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.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminManagementService implements AdminManagementSrv.Iface { + + static final int DEFAULT_ORGANIZATION_LIMIT = 20; + static final int MAX_ORGANIZATION_LIMIT = 1000; + + private static final String ACTIVE_STATUS = OrganizationStatus.active.name(); + private static final String DEACTIVATED_STATUS = OrganizationStatus.deactivated.name(); + + private final OrganizationRepository organizationRepository; + private final MemberRepository memberRepository; + private final MemberRoleRepository memberRoleRepository; + private final OrganizationRoleRepository organizationRoleRepository; + private final InvitationRepository invitationRepository; + private final AdminManagementConverter converter; + private final InviteTokenProperties inviteTokenProperties; + private final MailMessageSender mailMessageSender; + + @Override + @Transactional + public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + 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(request.getMetadata()) + .createdAt(LocalDateTime.now()) + .status(ACTIVE_STATUS) + .members(new HashSet<>()) + .roles(new HashSet<>()) + .build(); + try { + return converter.toOrganization(organizationRepository.saveAndFlush(entity)); + } catch (DataIntegrityViolationException exception) { + throw new PartyAlreadyBound(); + } + } + + @Override + public Organization getOrganization(String organizationId) throws OrganizationNotFound { + return converter.toOrganization(findOrganization(organizationId)); + } + + @Override + public ListOrganizationsResult listOrganizations(ListOrganizationsRequest 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; + } + + @Override + @Transactional + public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + organization.setName(name); + return converter.toOrganization(organizationRepository.save(organization)); + } + + @Override + @Transactional + public Organization deactivateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return changeOrganizationStatus(organizationId, ACTIVE_STATUS, DEACTIVATED_STATUS); + } + + @Override + @Transactional + public Organization activateOrganization(String organizationId) + throws OrganizationNotFound, InvalidOrganizationState { + return changeOrganizationStatus(organizationId, DEACTIVATED_STATUS, ACTIVE_STATUS); + } + + @Override + public Member getMember(String organizationId, String userId) + throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return converter.toMember(findMember(organization, userId), organizationId); + } + + @Override + public List listMembers(String organizationId) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return collectionOrEmpty(organization.getMembers()).stream() + .sorted(Comparator.comparing(MemberEntity::getId)) + .map(member -> converter.toMember(member, organizationId)) + .toList(); + } + + @Override + @Transactional + public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + OrganizationEntity organization = 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); + } + + @Override + @Transactional + public void removeMember(String organizationId, String userId) + throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = 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); + } + + @Override + @Transactional + public MemberRole assignMemberRole( + String organizationId, + String userId, + AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + OrganizationEntity organization = findOrganization(organizationId); + MemberEntity member = findMember(organization, userId); + MemberRoleEntity role = 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); + } + + @Override + @Transactional + public void removeMemberRole(String organizationId, String userId, String memberRoleId) + throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { + OrganizationEntity organization = 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); + } + + @Override + public OrganizationRole getOrganizationRole(String organizationId, String roleId) + throws OrganizationNotFound { + findOrganization(organizationId); + OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) + .orElseThrow(OrganizationNotFound::new); + return converter.toOrganizationRole(role); + } + + @Override + public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { + OrganizationEntity organization = findOrganization(organizationId); + return collectionOrEmpty(organization.getRoles()).stream() + .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) + .map(converter::toOrganizationRole) + .toList(); + } + + @Override + @Transactional + public Invitation createInvitation(String organizationId, CreateInvitationRequest request) + throws OrganizationNotFound { + findOrganization(organizationId); + Set roles = request.getRoles().stream() + .map(role -> toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) + .collect(java.util.stream.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(InviteeContact.TypeEnum.E_MAIL.getValue()) + .inviteeContactEmail(request.getEmail()) + .inviteeRoles(roles) + .metadata(request.getMetadata()) + .status(InvitationStatusName.PENDING.getValue()) + .build(); + invitation = invitationRepository.save(invitation); + mailMessageSender.send(invitation.getAcceptToken(), invitation.getInviteeContactEmail()); + return converter.toInvitation(invitation); + } + + @Override + public Invitation getInvitation(String organizationId, String invitationId) + throws OrganizationNotFound, InvitationNotFound { + findOrganization(organizationId); + InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) + .orElseThrow(InvitationNotFound::new); + return converter.toInvitation(invitation); + } + + @Override + public List listInvitations(String organizationId, ListInvitationsRequest request) + throws OrganizationNotFound { + 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(); + } + + @Override + @Transactional + public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) + throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + findOrganization(organizationId); + InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) + .orElseThrow(InvitationNotFound::new); + InvitationStatus status = converter.effectiveInvitationStatus(invitation); + if (status != InvitationStatus.pending) { + throw new InvalidOrganizationState("Only a pending invitation can be revoked"); + } + invitation.setStatus(InvitationStatusName.REVOKED.getValue()); + invitation.setRevocationReason(request.getReason()); + invitation.setRevokedAt(LocalDateTime.now()); + invitationRepository.save(invitation); + } + + 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 changeOrganizationStatus(String organizationId, String expected, String target) + throws OrganizationNotFound, InvalidOrganizationState { + OrganizationEntity organization = findOrganization(organizationId); + String current = organization.getStatus() == null ? ACTIVE_STATUS : organization.getStatus(); + if (!expected.equals(current)) { + throw new InvalidOrganizationState( + "Expected organization status " + expected + ", but was " + current); + } + organization.setStatus(target); + return converter.toOrganization(organizationRepository.save(organization)); + } + + private OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { + return organizationRepository.findById(organizationId) + .orElseThrow(OrganizationNotFound::new); + } + + private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { + return collectionOrEmpty(organization.getMembers()).stream() + .filter(member -> member.getId().equals(userId)) + .findFirst() + .orElseThrow(MemberNotFound::new); + } + + private MemberRoleEntity toMemberRoleEntity( + String organizationId, + String roleId, + dev.vality.orgmanagement.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(); + } + + private Collection collectionOrEmpty(Collection collection) { + return collection == null ? List.of() : collection; + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java index 8d17c590..bed9546e 100644 --- a/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java +++ b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java @@ -34,7 +34,13 @@ 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 organization.getStatus() == null + || !organization.getStatus().equalsIgnoreCase("deactivated"); + } } 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/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java new file mode 100644 index 00000000..9e45345a --- /dev/null +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -0,0 +1,231 @@ +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.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 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 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.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() { + service = new AdminManagementService( + organizationRepository, + memberRepository, + memberRoleRepository, + organizationRoleRepository, + invitationRepository, + new AdminManagementConverter(), + inviteTokenProperties, + mailMessageSender); + } + + @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")); + } + + 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()); + } } From 43814591d614d4d4efc03b418f72cd0e7a9fbd7a Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 27 Aug 2026 13:34:18 +0400 Subject: [PATCH 04/19] bump --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1aadee48..b1695345 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.18-6b1eb95 + 1.19-e5d3e5f dev.vality From 43b53632734e06e4a5bc070179d48546eb22e711 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Mon, 31 Aug 2026 12:14:12 +0400 Subject: [PATCH 05/19] fix --- .../converter/AdminManagementConverter.java | 70 +++++++++++--- .../converter/InvitationConverter.java | 3 +- .../entity/StoredInvitationStatus.java | 35 +++++++ .../entity/StoredInviteeContactType.java | 19 ++++ .../repository/MemberRepository.java | 26 ++++++ .../service/AdminManagementService.java | 63 ++++++++++--- .../orgmanager/service/InvitationService.java | 9 +- .../service/OrganizationService.java | 2 +- .../entity/StoredInvitationStatusTest.java | 34 +++++++ .../service/AdminManagementServiceTest.java | 91 ++++++++++++++++++- 10 files changed, 322 insertions(+), 30 deletions(-) create mode 100644 src/main/java/dev/vality/orgmanager/entity/StoredInvitationStatus.java create mode 100644 src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java create mode 100644 src/test/java/dev/vality/orgmanager/entity/StoredInvitationStatusTest.java diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java index 476e3989..c48adffd 100644 --- a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -14,19 +14,26 @@ 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(), @@ -35,8 +42,9 @@ public Organization toOrganization(OrganizationEntity entity) { entity.getName(), formatDateTime(entity.getCreatedAt()), toOrganizationStatus(entity.getStatus())); - if (entity.getMetadata() != null) { - organization.setMetadata(entity.getMetadata()); + String metadata = metadataOrNull(entity.getMetadata()); + if (metadata != null) { + organization.setMetadata(metadata); } return organization; } @@ -55,10 +63,39 @@ public Member toMember(MemberEntity entity, String organizationId) { 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)); + 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; } @@ -66,7 +103,7 @@ public MemberRole toMemberRole(MemberRoleEntity entity) { public RoleAssignment toRoleAssignment(MemberRoleEntity entity) { RoleAssignment assignment = new RoleAssignment(entity.getRoleId()); if (entity.getScopeId() != null) { - assignment.setScope(toRoleScope(entity)); + assignment.setScope(toRoleScope(entity.getScopeId(), entity.getResourceId())); } return assignment; } @@ -84,8 +121,9 @@ public Invitation toInvitation(InvitationEntity entity) { entity.getInviteeContactEmail(), roles, effectiveInvitationStatus(entity)); - if (entity.getMetadata() != null) { - invitation.setMetadata(entity.getMetadata()); + String metadata = metadataOrNull(entity.getMetadata()); + if (metadata != null) { + invitation.setMetadata(metadata); } if (entity.getAcceptedAt() != null) { invitation.setAcceptedAt(formatDateTime(entity.getAcceptedAt())); @@ -119,10 +157,10 @@ public OrganizationRole toOrganizationRole(OrganizationRoleEntity entity) { return new OrganizationRole(entity.getRoleId(), entity.getName(), scopeIds); } - private RoleScope toRoleScope(MemberRoleEntity entity) { - RoleScope scope = new RoleScope(entity.getScopeId()); - if (entity.getResourceId() != null) { - scope.setResourceId(entity.getResourceId()); + private RoleScope toRoleScope(String scopeId, String resourceId) { + RoleScope scope = new RoleScope(scopeId); + if (resourceId != null) { + scope.setResourceId(resourceId); } return scope; } @@ -135,7 +173,17 @@ private OrganizationStatus toOrganizationStatus(String status) { } private InvitationStatus toInvitationStatus(String status) { - return InvitationStatus.valueOf(status.toLowerCase(Locale.ROOT)); + 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) { 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/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..21f885f3 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java @@ -0,0 +1,19 @@ +package dev.vality.orgmanager.entity; + +/** + * Значения колонки {@code invitation.invitee_contact_type}. + */ +public enum StoredInviteeContactType { + + EMAIL("EMail"); + + private final String value; + + StoredInviteeContactType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} 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/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java index f014a7a7..ef90bf77 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -29,14 +29,16 @@ 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.entity.StoredInviteeContactType; 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.swag.organizations.model.InvitationStatusName; -import dev.vality.swag.organizations.model.InviteeContact; +import dev.vality.orgmanager.util.JsonCodec; 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; @@ -53,6 +55,7 @@ import java.util.Set; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -72,10 +75,12 @@ public class AdminManagementService implements AdminManagementSrv.Iface { private final AdminManagementConverter converter; private final InviteTokenProperties inviteTokenProperties; private final MailMessageSender mailMessageSender; + private final JsonCodec jsonCodec; @Override @Transactional public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + log.info("Create organization: partyId={}, ownerId={}", request.getPartyId(), request.getOwnerId()); if (organizationRepository.existsByParty(request.getPartyId())) { throw new PartyAlreadyBound(); } @@ -84,7 +89,7 @@ public Organization createOrganization(CreateOrganizationRequest request) throws .party(request.getPartyId()) .owner(request.getOwnerId()) .name(request.getName()) - .metadata(request.getMetadata()) + .metadata(toStoredMetadata(request.getMetadata())) .createdAt(LocalDateTime.now()) .status(ACTIVE_STATUS) .members(new HashSet<>()) @@ -99,11 +104,13 @@ public Organization createOrganization(CreateOrganizationRequest request) throws @Override public Organization getOrganization(String organizationId) throws OrganizationNotFound { + log.info("Get organization: organizationId={}", organizationId); return converter.toOrganization(findOrganization(organizationId)); } @Override public ListOrganizationsResult listOrganizations(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); @@ -125,6 +132,7 @@ public ListOrganizationsResult listOrganizations(ListOrganizationsRequest reques @Override @Transactional public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { + log.info("Rename organization: organizationId={}, name={}", organizationId, name); OrganizationEntity organization = findOrganization(organizationId); organization.setName(name); return converter.toOrganization(organizationRepository.save(organization)); @@ -134,6 +142,7 @@ public Organization renameOrganization(String organizationId, String name) throw @Transactional public Organization deactivateOrganization(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { + log.info("Deactivate organization: organizationId={}", organizationId); return changeOrganizationStatus(organizationId, ACTIVE_STATUS, DEACTIVATED_STATUS); } @@ -141,28 +150,32 @@ public Organization deactivateOrganization(String organizationId) @Transactional public Organization activateOrganization(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { + log.info("Activate organization: organizationId={}", organizationId); return changeOrganizationStatus(organizationId, DEACTIVATED_STATUS, ACTIVE_STATUS); } @Override public Member getMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + log.info("Get member: organizationId={}, userId={}", organizationId, userId); OrganizationEntity organization = findOrganization(organizationId); return converter.toMember(findMember(organization, userId), organizationId); } @Override public List listMembers(String organizationId) throws OrganizationNotFound { - OrganizationEntity organization = findOrganization(organizationId); - return collectionOrEmpty(organization.getMembers()).stream() - .sorted(Comparator.comparing(MemberEntity::getId)) - .map(member -> converter.toMember(member, organizationId)) - .toList(); + log.info("List members: organizationId={}", organizationId); + if (!organizationRepository.existsById(organizationId)) { + throw new OrganizationNotFound(); + } + return converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId)); } @Override @Transactional public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); + // TODO: email в AddMemberRequest опционален, но колонка member.email объявлена NOT NULL OrganizationEntity organization = findOrganization(organizationId); MemberEntity member = memberRepository.findById(request.getUserId()) .orElseGet(() -> MemberEntity.builder() @@ -185,6 +198,7 @@ public Member addMember(String organizationId, AddMemberRequest request) throws @Transactional public void removeMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + log.info("Remove member: organizationId={}, userId={}", organizationId, userId); OrganizationEntity organization = findOrganization(organizationId); MemberEntity member = findMember(organization, userId); @@ -214,6 +228,8 @@ public MemberRole assignMemberRole( String organizationId, String userId, AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + log.info("Assign member role: organizationId={}, userId={}, roleId={}", + organizationId, userId, request.getRoleId()); OrganizationEntity organization = findOrganization(organizationId); MemberEntity member = findMember(organization, userId); MemberRoleEntity role = toMemberRoleEntity(organizationId, request.getRoleId(), request.getScope()); @@ -229,6 +245,12 @@ public MemberRole assignMemberRole( @Transactional public void removeMemberRole(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 = findOrganization(organizationId); MemberEntity member = findMember(organization, userId); MemberRoleEntity role = collectionOrEmpty(member.getRoles()).stream() @@ -248,6 +270,7 @@ public void removeMemberRole(String organizationId, String userId, String member @Override public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound { + log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); findOrganization(organizationId); OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) .orElseThrow(OrganizationNotFound::new); @@ -256,6 +279,7 @@ public OrganizationRole getOrganizationRole(String organizationId, String roleId @Override public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { + log.info("List organization roles: organizationId={}", organizationId); OrganizationEntity organization = findOrganization(organizationId); return collectionOrEmpty(organization.getRoles()).stream() .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) @@ -267,6 +291,7 @@ public List listOrganizationRoles(String organizationId) throw @Transactional public Invitation createInvitation(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound { + log.info("Create invitation: organizationId={}, email={}", organizationId, request.getEmail()); findOrganization(organizationId); Set roles = request.getRoles().stream() .map(role -> toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) @@ -278,11 +303,11 @@ public Invitation createInvitation(String organizationId, CreateInvitationReques .createdAt(now) .expiresAt(now.plusDays(inviteTokenProperties.getLifeTimeInDays())) .acceptToken(UUID.randomUUID().toString()) - .inviteeContactType(InviteeContact.TypeEnum.E_MAIL.getValue()) + .inviteeContactType(StoredInviteeContactType.EMAIL.getValue()) .inviteeContactEmail(request.getEmail()) .inviteeRoles(roles) - .metadata(request.getMetadata()) - .status(InvitationStatusName.PENDING.getValue()) + .metadata(toStoredMetadata(request.getMetadata())) + .status(StoredInvitationStatus.PENDING.getValue()) .build(); invitation = invitationRepository.save(invitation); mailMessageSender.send(invitation.getAcceptToken(), invitation.getInviteeContactEmail()); @@ -292,6 +317,7 @@ public Invitation createInvitation(String organizationId, CreateInvitationReques @Override public Invitation getInvitation(String organizationId, String invitationId) throws OrganizationNotFound, InvitationNotFound { + log.info("Get invitation: organizationId={}, invitationId={}", organizationId, invitationId); findOrganization(organizationId); InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) .orElseThrow(InvitationNotFound::new); @@ -301,6 +327,7 @@ public Invitation getInvitation(String organizationId, String invitationId) @Override public List listInvitations(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { + log.info("List invitations: organizationId={}, request={}", organizationId, request); findOrganization(organizationId); InvitationStatus status = request == null ? null : request.getStatus(); return invitationRepository.findByOrganizationId(organizationId).stream() @@ -314,6 +341,7 @@ public List listInvitations(String organizationId, ListInvitationsRe @Transactional public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + log.info("Revoke invitation: organizationId={}, invitationId={}", organizationId, invitationId); findOrganization(organizationId); InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) .orElseThrow(InvitationNotFound::new); @@ -321,7 +349,7 @@ public void revokeInvitation(String organizationId, String invitationId, RevokeI if (status != InvitationStatus.pending) { throw new InvalidOrganizationState("Only a pending invitation can be revoked"); } - invitation.setStatus(InvitationStatusName.REVOKED.getValue()); + invitation.setStatus(StoredInvitationStatus.REVOKED.getValue()); invitation.setRevocationReason(request.getReason()); invitation.setRevokedAt(LocalDateTime.now()); invitationRepository.save(invitation); @@ -361,6 +389,17 @@ private Organization changeOrganizationStatus(String organizationId, String expe return converter.toOrganization(organizationRepository.save(organization)); } + private 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); + } + } + private OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { return organizationRepository.findById(organizationId) .orElseThrow(OrganizationNotFound::new); 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/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 index 9e45345a..cba8ba47 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -9,6 +9,7 @@ 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; @@ -23,12 +24,15 @@ 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; @@ -38,6 +42,7 @@ 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; @@ -76,7 +81,8 @@ void setUp() { invitationRepository, new AdminManagementConverter(), inviteTokenProperties, - mailMessageSender); + mailMessageSender, + new JsonCodec(JsonMapper.builder().build())); } @Test @@ -204,6 +210,89 @@ void shouldCreateInvitationAndSendMail() throws Exception { 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) From 397f59d59c65b8597d726418074ae6808f577e70 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Tue, 1 Sep 2026 18:08:22 +0400 Subject: [PATCH 06/19] fix --- pom.xml | 2 +- .../entity/StoredInviteeContactType.java | 13 +- .../entity/StoredOrganizationStatus.java | 30 ++ .../service/AdminCommonService.java | 53 +++ .../service/AdminInvitationService.java | 110 ++++++ .../service/AdminManagementService.java | 361 ++---------------- .../service/AdminMemberService.java | 157 ++++++++ .../service/AdminOrganizationService.java | 176 +++++++++ .../orgmanager/service/UserServiceImpl.java | 4 +- .../service/AdminManagementServiceTest.java | 28 +- 10 files changed, 585 insertions(+), 349 deletions(-) create mode 100644 src/main/java/dev/vality/orgmanager/entity/StoredOrganizationStatus.java create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminCommonService.java create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminMemberService.java create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java diff --git a/pom.xml b/pom.xml index b1695345..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 diff --git a/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java b/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java index 21f885f3..309e4798 100644 --- a/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java +++ b/src/main/java/dev/vality/orgmanager/entity/StoredInviteeContactType.java @@ -1,19 +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; - - StoredInviteeContactType(String value) { - this.value = value; - } - - public String getValue() { - return 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/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 index ef90bf77..0c965fca 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -8,7 +8,6 @@ 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.ListOrganizationsRequest; import dev.vality.orgmanagement.ListOrganizationsResult; @@ -19,414 +18,116 @@ 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.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.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.StoredInvitationStatus; -import dev.vality.orgmanager.entity.StoredInviteeContactType; -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.util.JsonCodec; 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.Collection; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; -import java.util.UUID; -@Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class AdminManagementService implements AdminManagementSrv.Iface { - static final int DEFAULT_ORGANIZATION_LIMIT = 20; - static final int MAX_ORGANIZATION_LIMIT = 1000; - - private static final String ACTIVE_STATUS = OrganizationStatus.active.name(); - private static final String DEACTIVATED_STATUS = OrganizationStatus.deactivated.name(); - - private final OrganizationRepository organizationRepository; - private final MemberRepository memberRepository; - private final MemberRoleRepository memberRoleRepository; - private final OrganizationRoleRepository organizationRoleRepository; - private final InvitationRepository invitationRepository; - private final AdminManagementConverter converter; - private final InviteTokenProperties inviteTokenProperties; - private final MailMessageSender mailMessageSender; - private final JsonCodec jsonCodec; + private final AdminOrganizationService adminOrganizationService; + private final AdminMemberService adminMemberService; + private final AdminInvitationService adminInvitationService; @Override - @Transactional public Organization createOrganization(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(toStoredMetadata(request.getMetadata())) - .createdAt(LocalDateTime.now()) - .status(ACTIVE_STATUS) - .members(new HashSet<>()) - .roles(new HashSet<>()) - .build(); - try { - return converter.toOrganization(organizationRepository.saveAndFlush(entity)); - } catch (DataIntegrityViolationException exception) { - throw new PartyAlreadyBound(); - } + return adminOrganizationService.create(request); } @Override public Organization getOrganization(String organizationId) throws OrganizationNotFound { - log.info("Get organization: organizationId={}", organizationId); - return converter.toOrganization(findOrganization(organizationId)); + return adminOrganizationService.get(organizationId); } @Override public ListOrganizationsResult listOrganizations(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; + return adminOrganizationService.list(request); } @Override - @Transactional public Organization renameOrganization(String organizationId, String name) throws OrganizationNotFound { - log.info("Rename organization: organizationId={}, name={}", organizationId, name); - OrganizationEntity organization = findOrganization(organizationId); - organization.setName(name); - return converter.toOrganization(organizationRepository.save(organization)); + return adminOrganizationService.rename(organizationId, name); } @Override - @Transactional public Organization deactivateOrganization(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { - log.info("Deactivate organization: organizationId={}", organizationId); - return changeOrganizationStatus(organizationId, ACTIVE_STATUS, DEACTIVATED_STATUS); + return adminOrganizationService.deactivate(organizationId); } @Override - @Transactional public Organization activateOrganization(String organizationId) throws OrganizationNotFound, InvalidOrganizationState { - log.info("Activate organization: organizationId={}", organizationId); - return changeOrganizationStatus(organizationId, DEACTIVATED_STATUS, ACTIVE_STATUS); + return adminOrganizationService.activate(organizationId); } @Override - public Member getMember(String organizationId, String userId) - throws OrganizationNotFound, MemberNotFound { - log.info("Get member: organizationId={}, userId={}", organizationId, userId); - OrganizationEntity organization = findOrganization(organizationId); - return converter.toMember(findMember(organization, userId), organizationId); + public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound { + return adminOrganizationService.getRole(organizationId, roleId); } @Override - public List listMembers(String organizationId) throws OrganizationNotFound { - log.info("List members: organizationId={}", organizationId); - if (!organizationRepository.existsById(organizationId)) { - throw new OrganizationNotFound(); - } - return converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId)); + public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { + return adminOrganizationService.listRoles(organizationId); } @Override - @Transactional - public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { - log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); - // TODO: email в AddMemberRequest опционален, но колонка member.email объявлена NOT NULL - OrganizationEntity organization = 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); + public Member getMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + return adminMemberService.get(organizationId, userId); } @Override - @Transactional - public void removeMember(String organizationId, String userId) - throws OrganizationNotFound, MemberNotFound { - log.info("Remove member: organizationId={}, userId={}", organizationId, userId); - OrganizationEntity organization = 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); + public List listMembers(String organizationId) throws OrganizationNotFound { + return adminMemberService.list(organizationId); } @Override - @Transactional - public MemberRole assignMemberRole( - String organizationId, - String userId, - AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { - log.info("Assign member role: organizationId={}, userId={}, roleId={}", - organizationId, userId, request.getRoleId()); - OrganizationEntity organization = findOrganization(organizationId); - MemberEntity member = findMember(organization, userId); - MemberRoleEntity role = 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); + public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + return adminMemberService.add(organizationId, request); } @Override - @Transactional - public void removeMemberRole(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 = 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); + public void removeMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { + adminMemberService.remove(organizationId, userId); } @Override - public OrganizationRole getOrganizationRole(String organizationId, String roleId) - throws OrganizationNotFound { - log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); - findOrganization(organizationId); - OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(OrganizationNotFound::new); - return converter.toOrganizationRole(role); + public MemberRole assignMemberRole(String organizationId, String userId, AssignMemberRoleRequest request) + throws OrganizationNotFound, MemberNotFound { + return adminMemberService.assignRole(organizationId, userId, request); } @Override - public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { - log.info("List organization roles: organizationId={}", organizationId); - OrganizationEntity organization = findOrganization(organizationId); - return collectionOrEmpty(organization.getRoles()).stream() - .sorted(Comparator.comparing(OrganizationRoleEntity::getRoleId)) - .map(converter::toOrganizationRole) - .toList(); + public void removeMemberRole(String organizationId, String userId, String memberRoleId) + throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { + adminMemberService.removeRole(organizationId, userId, memberRoleId); } @Override - @Transactional public Invitation createInvitation(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound { - log.info("Create invitation: organizationId={}, email={}", organizationId, request.getEmail()); - findOrganization(organizationId); - Set roles = request.getRoles().stream() - .map(role -> toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())) - .collect(java.util.stream.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(toStoredMetadata(request.getMetadata())) - .status(StoredInvitationStatus.PENDING.getValue()) - .build(); - invitation = invitationRepository.save(invitation); - mailMessageSender.send(invitation.getAcceptToken(), invitation.getInviteeContactEmail()); - return converter.toInvitation(invitation); + return adminInvitationService.create(organizationId, request); } @Override public Invitation getInvitation(String organizationId, String invitationId) throws OrganizationNotFound, InvitationNotFound { - log.info("Get invitation: organizationId={}, invitationId={}", organizationId, invitationId); - findOrganization(organizationId); - InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) - .orElseThrow(InvitationNotFound::new); - return converter.toInvitation(invitation); + return adminInvitationService.get(organizationId, invitationId); } @Override public List listInvitations(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { - log.info("List invitations: organizationId={}, request={}", organizationId, request); - 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(); + return adminInvitationService.list(organizationId, request); } @Override - @Transactional public void revokeInvitation(String organizationId, String invitationId, RevokeInvitationRequest request) throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { - log.info("Revoke invitation: organizationId={}, invitationId={}", organizationId, invitationId); - findOrganization(organizationId); - InvitationEntity invitation = invitationRepository.findByIdAndOrganizationId(invitationId, organizationId) - .orElseThrow(InvitationNotFound::new); - 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 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 changeOrganizationStatus(String organizationId, String expected, String target) - throws OrganizationNotFound, InvalidOrganizationState { - OrganizationEntity organization = findOrganization(organizationId); - String current = organization.getStatus() == null ? ACTIVE_STATUS : organization.getStatus(); - if (!expected.equals(current)) { - throw new InvalidOrganizationState( - "Expected organization status " + expected + ", but was " + current); - } - organization.setStatus(target); - return converter.toOrganization(organizationRepository.save(organization)); - } - - private 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); - } - } - - private OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { - return organizationRepository.findById(organizationId) - .orElseThrow(OrganizationNotFound::new); - } - - private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { - return collectionOrEmpty(organization.getMembers()).stream() - .filter(member -> member.getId().equals(userId)) - .findFirst() - .orElseThrow(MemberNotFound::new); - } - - private MemberRoleEntity toMemberRoleEntity( - String organizationId, - String roleId, - dev.vality.orgmanagement.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(); - } - - private Collection collectionOrEmpty(Collection collection) { - return collection == null ? List.of() : collection; + 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/UserServiceImpl.java b/src/main/java/dev/vality/orgmanager/service/UserServiceImpl.java index bed9546e..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; @@ -40,7 +41,6 @@ public UserInfo findById(String id) { } private boolean isActive(OrganizationEntity organization) { - return organization.getStatus() == null - || !organization.getStatus().equalsIgnoreCase("deactivated"); + return StoredOrganizationStatus.ACTIVE.matches(organization.getStatus()); } } diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index cba8ba47..ae94510d 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -73,16 +73,28 @@ class AdminManagementServiceTest { @BeforeEach void setUp() { - service = new AdminManagementService( + AdminManagementConverter converter = new AdminManagementConverter(); + AdminCommonService commonService = new AdminCommonService( organizationRepository, - memberRepository, - memberRoleRepository, - organizationRoleRepository, - invitationRepository, - new AdminManagementConverter(), - inviteTokenProperties, - mailMessageSender, 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 From e5af290eb243bca422338385d4a95cb51838e04a Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 3 Sep 2026 16:24:02 +0400 Subject: [PATCH 07/19] fix SecurityConfig --- .../java/dev/vality/orgmanager/config/SecurityConfig.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java b/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java index 71c11b5a..113d4859 100644 --- a/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java +++ b/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java @@ -1,5 +1,6 @@ package dev.vality.orgmanager.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,6 +20,9 @@ @ConditionalOnProperty(value = "auth.enabled", havingValue = "true") public class SecurityConfig { + @Value("${server.port}") + private int thriftPort; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -39,6 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/prometheus" ).permitAll() .requestMatchers("/auth-context").permitAll() + .requestMatchers(request -> "/admin-management".equals(request.getRequestURI()) + && request.getLocalPort() == thriftPort).permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(config -> config.jwt(Customizer.withDefaults())); return http.build(); From 06ce72606aee57c13373cdd91c29f3b3691f5180 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 3 Sep 2026 16:31:12 +0400 Subject: [PATCH 08/19] fix SecurityConfig --- .../dev/vality/orgmanager/config/SecurityConfig.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java b/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java index 113d4859..565a3ab4 100644 --- a/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java +++ b/src/main/java/dev/vality/orgmanager/config/SecurityConfig.java @@ -1,5 +1,6 @@ package dev.vality.orgmanager.config; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; @@ -20,6 +21,8 @@ @ConditionalOnProperty(value = "auth.enabled", havingValue = "true") public class SecurityConfig { + private static final String ADMIN_MANAGEMENT_PATH = "/admin-management"; + @Value("${server.port}") private int thriftPort; @@ -43,13 +46,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/prometheus" ).permitAll() .requestMatchers("/auth-context").permitAll() - .requestMatchers(request -> "/admin-management".equals(request.getRequestURI()) - && request.getLocalPort() == thriftPort).permitAll() + .requestMatchers(this::isThriftAdminManagementRequest).permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(config -> config.jwt(Customizer.withDefaults())); return http.build(); } + private boolean isThriftAdminManagementRequest(HttpServletRequest request) { + return request.getLocalPort() == thriftPort + && (request.getContextPath() + ADMIN_MANAGEMENT_PATH).equals(request.getRequestURI()); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); From 1b4daad0e4c63a4d887538ad9c6de91707f2782c Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 11:03:23 +0400 Subject: [PATCH 09/19] Bump org-management-proto --- pom.xml | 2 +- .../repository/InvitationRepository.java | 7 +- .../repository/MemberRepository.java | 37 +- .../repository/OrganizationRepository.java | 17 +- .../repository/ScopeRepository.java | 9 + .../service/AdminCommonService.java | 46 ++- .../service/AdminInvitationService.java | 141 ++++++- .../service/AdminManagementService.java | 59 ++- .../service/AdminMemberService.java | 62 ++- .../service/AdminOrganizationService.java | 112 ++++-- .../repository/AbstractRepositoryTest.java | 2 +- .../service/AdminManagementServiceTest.java | 361 ++++++++++++++++-- 12 files changed, 710 insertions(+), 145 deletions(-) create mode 100644 src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java diff --git a/pom.xml b/pom.xml index 979a69ab..86a4b895 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.19-e5d3e5f + 1.21-23aa5d8 dev.vality diff --git a/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java b/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java index d58fc071..90e79679 100644 --- a/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/InvitationRepository.java @@ -1,13 +1,13 @@ package dev.vality.orgmanager.repository; import dev.vality.orgmanager.entity.InvitationEntity; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.stereotype.Repository; -import jakarta.persistence.QueryHint; - import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -15,7 +15,8 @@ import static org.hibernate.jpa.QueryHints.HINT_FETCH_SIZE; @Repository -public interface InvitationRepository extends JpaRepository { +public interface InvitationRepository + extends JpaRepository, JpaSpecificationExecutor { List findByOrganizationIdAndStatus(String organizationId, String status); diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index a8b2cb83..387e12e6 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -2,10 +2,12 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; @Repository @@ -33,10 +35,34 @@ public interface MemberRepository extends JpaRepository { List getOrgMemberList(String orgId); /** - * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний: участник без активных - * ролей в организации тоже попадает в выдачу. Административный контракт умеет добавлять - * участника без роли (AddMember), и такой участник не должен пропадать из списка. - * У строк для участника без ролей memberRoleId == null. + * Страница идентификаторов участников организации, отсортированная по возрастанию id. + */ + @NativeQuery("SELECT m.id " + + " FROM org_manager.member_to_organization mto " + + " JOIN org_manager.member m " + + " ON m.id = mto.member_id " + + " WHERE mto.organization_id = ?1 " + + " ORDER BY m.id ") + List getOrgMemberIds(String orgId, Pageable pageable); + + /** + * То же, что {@link #getOrgMemberIds(String, Pageable)}, но со сдвигом по continuation token: + * возвращаются участники, идущие строго после указанного id. + */ + @NativeQuery("SELECT m.id " + + " FROM org_manager.member_to_organization mto " + + " JOIN org_manager.member m " + + " ON m.id = mto.member_id " + + " WHERE mto.organization_id = ?1 " + + " AND m.id > ?2 " + + " ORDER BY m.id ") + List getOrgMemberIds(String orgId, String continuationToken, Pageable pageable); + + /** + * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний и выборка ограничена + * заданными участниками: участник без активных ролей в организации тоже попадает в выдачу. + * Административный контракт умеет добавлять участника без роли (AddMember), и такой участник + * не должен пропадать из списка. У строк для участника без ролей memberRoleId == null. */ @NativeQuery("SELECT m.id, " + " m.email, " + @@ -55,8 +81,9 @@ public interface MemberRepository extends JpaRepository { " AND mr.active IS TRUE " + " AND mr.organization_id = mto.organization_id " + " WHERE mto.organization_id = ?1 " + + " AND m.id IN (?2) " + " ORDER BY m.id, mr.id ") - List getOrgMemberListWithRoles(String orgId); + List getOrgMemberListWithRoles(String orgId, Collection memberIds); 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 808daa29..bfbe3acf 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -2,15 +2,17 @@ import dev.vality.orgmanager.entity.OrganizationEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.NativeQuery; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.Set; @Repository -public interface OrganizationRepository extends JpaRepository { +public interface OrganizationRepository + extends JpaRepository, JpaSpecificationExecutor { @NativeQuery(" SELECT * FROM org_manager.organization AS o " + " WHERE o.id IN " + @@ -39,15 +41,6 @@ public interface OrganizationRepository extends JpaRepository findAllByOrderByIdDesc(Pageable pageable); - - List findByIdLessThanOrderByIdDesc(String id, Pageable pageable); - - List findByStatusOrderByIdDesc(String status, Pageable pageable); - - List findByStatusAndIdLessThanOrderByIdDesc( - String status, - String id, - Pageable pageable); + Optional findByParty(String party); } diff --git a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java new file mode 100644 index 00000000..2031b652 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java @@ -0,0 +1,9 @@ +package dev.vality.orgmanager.repository; + +import dev.vality.orgmanager.entity.ScopeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ScopeRepository extends JpaRepository { +} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java index ae334e54..298f677f 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -1,23 +1,31 @@ package dev.vality.orgmanager.service; +import dev.vality.orgmanagement.InvalidRequest; 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.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.repository.OrganizationRoleRepository; 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.Objects; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class AdminCommonService { private final OrganizationRepository organizationRepository; + private final OrganizationRoleRepository organizationRoleRepository; private final JsonCodec jsonCodec; public OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { @@ -25,17 +33,51 @@ public OrganizationEntity findOrganization(String organizationId) throws Organiz .orElseThrow(OrganizationNotFound::new); } - public String toStoredMetadata(String metadata) { + public String toStoredMetadata(String metadata) throws InvalidRequest { 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); + throw new InvalidRequest("Metadata is expected to be a JSON object"); } } + public String requireText(String value, String field) throws InvalidRequest { + if (value == null || value.isBlank()) { + throw new InvalidRequest(field + " must not be blank"); + } + return value; + } + + /** + * Проверяет, что назначение роли опирается на каталог ролей организации: сама роль в нём есть, + * а указанный scope_id входит в её список допустимых областей действия. + */ + public void validateRoleAssignment(String organizationId, String roleId, RoleScope scope) + throws InvalidRequest { + requireText(roleId, "Role id"); + OrganizationRoleEntity role = organizationRoleRepository + .findByOrganizationIdAndRoleId(organizationId, roleId) + .orElseThrow(() -> new InvalidRequest( + "Role " + roleId + " is not present in the organization role catalog")); + if (scope == null) { + return; + } + String scopeId = requireText(scope.getScopeId(), "Scope id"); + if (!allowedScopeIds(role).contains(scopeId)) { + throw new InvalidRequest("Scope " + scopeId + " is not allowed for role " + roleId); + } + } + + public Set allowedScopeIds(OrganizationRoleEntity role) { + return collectionOrEmpty(role.getPossibleScopes()).stream() + .map(ScopeEntity::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, RoleScope scope) { return MemberRoleEntity.builder() .id(UUID.randomUUID().toString()) diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 098fd975..1f8d1b99 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -1,13 +1,16 @@ package dev.vality.orgmanager.service; import dev.vality.orgmanagement.CreateInvitationRequest; -import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvalidInvitationState; +import dev.vality.orgmanagement.InvalidRequest; import dev.vality.orgmanagement.Invitation; import dev.vality.orgmanagement.InvitationNotFound; import dev.vality.orgmanagement.InvitationStatus; import dev.vality.orgmanagement.ListInvitationsRequest; +import dev.vality.orgmanagement.ListInvitationsResult; import dev.vality.orgmanagement.OrganizationNotFound; import dev.vality.orgmanagement.RevokeInvitationRequest; +import dev.vality.orgmanagement.RoleAssignment; import dev.vality.orgmanager.config.properties.InviteTokenProperties; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.InvitationEntity; @@ -15,18 +18,27 @@ import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.entity.StoredInviteeContactType; import dev.vality.orgmanager.repository.InvitationRepository; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Comparator; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; + +import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; /** * Приглашения в организации в административном контракте. @@ -36,6 +48,9 @@ @RequiredArgsConstructor public class AdminInvitationService { + static final int DEFAULT_INVITATION_LIMIT = 20; + static final int MAX_INVITATION_LIMIT = 1000; + private final InvitationRepository invitationRepository; private final InviteTokenProperties inviteTokenProperties; private final MailMessageSender mailMessageSender; @@ -43,12 +58,17 @@ public class AdminInvitationService { private final AdminCommonService commonService; @Transactional - public Invitation create(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound { + public Invitation create(String organizationId, CreateInvitationRequest request) + throws OrganizationNotFound, InvalidRequest { 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)); + String email = commonService.requireText(request.getEmail(), "Email"); + String metadata = commonService.toStoredMetadata(request.getMetadata()); + Set roles = new LinkedHashSet<>(); + for (RoleAssignment role : collectionOrEmpty(request.getRoles())) { + commonService.validateRoleAssignment(organizationId, role.getRoleId(), role.getScope()); + roles.add(commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())); + } LocalDateTime now = LocalDateTime.now(); InvitationEntity invitation = InvitationEntity.builder() .id(UUID.randomUUID().toString()) @@ -57,9 +77,9 @@ public Invitation create(String organizationId, CreateInvitationRequest request) .expiresAt(now.plusDays(inviteTokenProperties.getLifeTimeInDays())) .acceptToken(UUID.randomUUID().toString()) .inviteeContactType(StoredInviteeContactType.EMAIL.getValue()) - .inviteeContactEmail(request.getEmail()) + .inviteeContactEmail(email) .inviteeRoles(roles) - .metadata(commonService.toStoredMetadata(request.getMetadata())) + .metadata(metadata) .status(StoredInvitationStatus.PENDING.getValue()) .build(); invitation = invitationRepository.save(invitation); @@ -76,26 +96,59 @@ public Invitation get(String organizationId, String invitationId) } @Transactional(readOnly = true) - public List list(String organizationId, ListInvitationsRequest request) throws OrganizationNotFound { + public ListInvitationsResult 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(); + ListInvitationsRequest safeRequest = request == null ? new ListInvitationsRequest() : request; + int limit = invitationLimit(safeRequest); + + InvitationEntity cursor = null; + if (safeRequest.isSetContinuationToken()) { + cursor = invitationRepository + .findByIdAndOrganizationId(safeRequest.getContinuationToken(), organizationId) + .orElse(null); + if (cursor == null) { + return new ListInvitationsResult(List.of()); + } + } + + Pageable pageable = PageRequest.of(0, limit + 1, + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + Specification specification = specification( + organizationId, + safeRequest.isSetStatus() ? safeRequest.getStatus() : null, + LocalDateTime.now(), + cursor); + List page = new ArrayList<>( + invitationRepository.findAll(specification, pageable).getContent()); + + String continuationToken = null; + if (page.size() > limit) { + page = new ArrayList<>(page.subList(0, limit)); + continuationToken = page.get(page.size() - 1).getId(); + } + ListInvitationsResult result = new ListInvitationsResult( + page.stream().map(converter::toInvitation).toList()); + if (continuationToken != null) { + result.setContinuationToken(continuationToken); + } + return result; } + /** + * Отозвать можно только приглашение в статусе pending. + */ @Transactional public void revoke(String organizationId, String invitationId, RevokeInvitationRequest request) - throws OrganizationNotFound, InvitationNotFound, InvalidOrganizationState { + throws OrganizationNotFound, InvitationNotFound, InvalidInvitationState { 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"); + throw new InvalidInvitationState( + "Expected invitation status pending, but was " + status.name()); } invitation.setStatus(StoredInvitationStatus.REVOKED.getValue()); invitation.setRevocationReason(request.getReason()); @@ -103,6 +156,58 @@ public void revoke(String organizationId, String invitationId, RevokeInvitationR invitationRepository.save(invitation); } + /** + * Страница упорядочена по (created_at, id) убыванию, поэтому продолжение читается keyset-ом + * от приглашения, на которое указывает continuation token. + */ + private Specification specification( + String organizationId, + InvitationStatus status, + LocalDateTime now, + InvitationEntity cursor) { + return (root, query, builder) -> { + List predicates = new ArrayList<>(); + predicates.add(builder.equal(root.get("organizationId"), organizationId)); + if (status != null) { + predicates.add(statusPredicate(root, builder, status, now)); + } + if (cursor != null) { + Path createdAt = root.get("createdAt"); + predicates.add(builder.or( + builder.lessThan(createdAt, cursor.getCreatedAt()), + builder.and( + builder.equal(createdAt, cursor.getCreatedAt()), + builder.lessThan(root.get("id"), cursor.getId())))); + } + return builder.and(predicates.toArray(new Predicate[0])); + }; + } + + + private Predicate statusPredicate( + Root root, + CriteriaBuilder builder, + InvitationStatus status, + LocalDateTime now) { + Path stored = root.get("status"); + Predicate storedPending = builder.equal(stored, StoredInvitationStatus.PENDING.getValue()); + return switch (status) { + case pending -> builder.and(storedPending, builder.greaterThan(root.get("expiresAt"), now)); + case expired -> builder.or( + builder.equal(stored, StoredInvitationStatus.EXPIRED.getValue()), + builder.and(storedPending, builder.lessThanOrEqualTo(root.get("expiresAt"), now))); + case accepted -> builder.equal(stored, StoredInvitationStatus.ACCEPTED.getValue()); + case revoked -> builder.equal(stored, StoredInvitationStatus.REVOKED.getValue()); + }; + } + + private int invitationLimit(ListInvitationsRequest request) { + if (!request.isSetLimit() || request.getLimit() <= 0) { + return DEFAULT_INVITATION_LIMIT; + } + return Math.min(request.getLimit(), MAX_INVITATION_LIMIT); + } + 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 index 0c965fca..d129124b 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -1,25 +1,6 @@ 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 dev.vality.orgmanagement.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,7 +15,8 @@ public class AdminManagementService implements AdminManagementSrv.Iface { private final AdminInvitationService adminInvitationService; @Override - public Organization createOrganization(CreateOrganizationRequest request) throws PartyAlreadyBound { + public Organization createOrganization(CreateOrganizationRequest request) + throws PartyAlreadyBound, InvalidRequest { return adminOrganizationService.create(request); } @@ -43,14 +25,20 @@ public Organization getOrganization(String organizationId) throws OrganizationNo return adminOrganizationService.get(organizationId); } + @Override + public Organization getOrganizationByParty(String partyId) throws OrganizationNotFound { + return adminOrganizationService.getByParty(partyId); + } + @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); + public Organization modifyOrganization(String organizationId, ModifyOrganizationRequest request) + throws OrganizationNotFound, InvalidRequest { + return adminOrganizationService.modify(organizationId, request); } @Override @@ -66,7 +54,8 @@ public Organization activateOrganization(String organizationId) } @Override - public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound { + public OrganizationRole getOrganizationRole(String organizationId, String roleId) + throws OrganizationNotFound, RoleNotFound { return adminOrganizationService.getRole(organizationId, roleId); } @@ -75,18 +64,26 @@ public List listOrganizationRoles(String organizationId) throw return adminOrganizationService.listRoles(organizationId); } + @Override + public OrganizationRole setOrganizationRole(String organizationId, SetOrganizationRoleRequest request) + throws OrganizationNotFound, InvalidRequest { + return adminOrganizationService.setRole(organizationId, request); + } + @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); + public ListMembersResult listMembers(String organizationId, ListMembersRequest request) + throws OrganizationNotFound { + return adminMemberService.list(organizationId, request); } @Override - public Member addMember(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + public Member addMember(String organizationId, AddMemberRequest request) + throws OrganizationNotFound, InvalidRequest { return adminMemberService.add(organizationId, request); } @@ -97,7 +94,7 @@ public void removeMember(String organizationId, String userId) throws Organizati @Override public MemberRole assignMemberRole(String organizationId, String userId, AssignMemberRoleRequest request) - throws OrganizationNotFound, MemberNotFound { + throws OrganizationNotFound, MemberNotFound, InvalidRequest { return adminMemberService.assignRole(organizationId, userId, request); } @@ -109,7 +106,7 @@ public void removeMemberRole(String organizationId, String userId, String member @Override public Invitation createInvitation(String organizationId, CreateInvitationRequest request) - throws OrganizationNotFound { + throws OrganizationNotFound, InvalidRequest { return adminInvitationService.create(organizationId, request); } @@ -120,14 +117,14 @@ public Invitation getInvitation(String organizationId, String invitationId) } @Override - public List listInvitations(String organizationId, ListInvitationsRequest request) + public ListInvitationsResult 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 { + throws OrganizationNotFound, InvitationNotFound, InvalidInvitationState { 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 index 9b6a2b7b..2a4c73ea 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -2,6 +2,9 @@ import dev.vality.orgmanagement.AddMemberRequest; import dev.vality.orgmanagement.AssignMemberRoleRequest; +import dev.vality.orgmanagement.InvalidRequest; +import dev.vality.orgmanagement.ListMembersRequest; +import dev.vality.orgmanagement.ListMembersResult; import dev.vality.orgmanagement.Member; import dev.vality.orgmanagement.MemberNotFound; import dev.vality.orgmanagement.MemberRole; @@ -16,6 +19,8 @@ import dev.vality.orgmanager.repository.OrganizationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +39,9 @@ @RequiredArgsConstructor public class AdminMemberService { + static final int DEFAULT_MEMBER_LIMIT = 20; + static final int MAX_MEMBER_LIMIT = 1000; + private final OrganizationRepository organizationRepository; private final MemberRepository memberRepository; private final MemberRoleRepository memberRoleRepository; @@ -48,28 +56,48 @@ public Member get(String organizationId, String userId) throws OrganizationNotFo } @Transactional(readOnly = true) - public List list(String organizationId) throws OrganizationNotFound { - log.info("List members: organizationId={}", organizationId); + public ListMembersResult list(String organizationId, ListMembersRequest request) throws OrganizationNotFound { + log.info("List members: organizationId={}, request={}", organizationId, request); if (!organizationRepository.existsById(organizationId)) { throw new OrganizationNotFound(); } - return converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId)); + ListMembersRequest safeRequest = request == null ? new ListMembersRequest() : request; + int limit = memberLimit(safeRequest); + Pageable pageable = PageRequest.ofSize(limit + 1); + String token = safeRequest.getContinuationToken(); + List memberIds = new ArrayList<>(token == null + ? memberRepository.getOrgMemberIds(organizationId, pageable) + : memberRepository.getOrgMemberIds(organizationId, token, pageable)); + + String continuationToken = null; + if (memberIds.size() > limit) { + memberIds = new ArrayList<>(memberIds.subList(0, limit)); + continuationToken = memberIds.get(memberIds.size() - 1); + } + List members = memberIds.isEmpty() + ? List.of() + : converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId, memberIds)); + ListMembersResult result = new ListMembersResult(members); + if (continuationToken != null) { + result.setContinuationToken(continuationToken); + } + return result; } @Transactional - public Member add(String organizationId, AddMemberRequest request) throws OrganizationNotFound { + public Member add(String organizationId, AddMemberRequest request) + throws OrganizationNotFound, InvalidRequest { log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); - // TODO: email в AddMemberRequest опционален, но колонка member.email объявлена NOT NULL + String userId = commonService.requireText(request.getUserId(), "User id"); + String email = commonService.requireText(request.getEmail(), "Email"); OrganizationEntity organization = commonService.findOrganization(organizationId); - MemberEntity member = memberRepository.findById(request.getUserId()) + MemberEntity member = memberRepository.findById(userId) .orElseGet(() -> MemberEntity.builder() - .id(request.getUserId()) + .id(userId) .roles(new HashSet<>()) .organizations(new HashSet<>()) .build()); - if (request.isSetEmail()) { - member.setEmail(request.getEmail()); - } + member.setEmail(email); member = memberRepository.save(member); Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); members.add(member); @@ -108,11 +136,12 @@ public void remove(String organizationId, String userId) throws OrganizationNotF public MemberRole assignRole( String organizationId, String userId, - AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound { + AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound, InvalidRequest { log.info("Assign member role: organizationId={}, userId={}, roleId={}", organizationId, userId, request.getRoleId()); OrganizationEntity organization = commonService.findOrganization(organizationId); MemberEntity member = findMember(organization, userId); + commonService.validateRoleAssignment(organizationId, request.getRoleId(), request.getScope()); MemberRoleEntity role = commonService.toMemberRoleEntity( organizationId, request.getRoleId(), request.getScope()); role = memberRoleRepository.save(role); @@ -128,10 +157,6 @@ 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() @@ -148,6 +173,13 @@ public void removeRole(String organizationId, String userId, String memberRoleId memberRepository.save(member); } + private int memberLimit(ListMembersRequest request) { + if (!request.isSetLimit() || request.getLimit() <= 0) { + return DEFAULT_MEMBER_LIMIT; + } + return Math.min(request.getLimit(), MAX_MEMBER_LIMIT); + } + private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { return collectionOrEmpty(organization.getMembers()).stream() .filter(member -> member.getId().equals(userId)) diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index d28b2da6..58d7b129 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -2,24 +2,32 @@ import dev.vality.orgmanagement.CreateOrganizationRequest; import dev.vality.orgmanagement.InvalidOrganizationState; +import dev.vality.orgmanagement.InvalidRequest; import dev.vality.orgmanagement.ListOrganizationsRequest; import dev.vality.orgmanagement.ListOrganizationsResult; +import dev.vality.orgmanagement.ModifyOrganizationRequest; 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.orgmanagement.RoleNotFound; +import dev.vality.orgmanagement.SetOrganizationRoleRequest; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.OrganizationEntity; import dev.vality.orgmanager.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.entity.StoredOrganizationStatus; import dev.vality.orgmanager.repository.OrganizationRepository; import dev.vality.orgmanager.repository.OrganizationRoleRepository; +import dev.vality.orgmanager.repository.ScopeRepository; +import jakarta.persistence.criteria.Predicate; 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.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,7 +35,9 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; @@ -45,12 +55,17 @@ public class AdminOrganizationService { private final OrganizationRepository organizationRepository; private final OrganizationRoleRepository organizationRoleRepository; + private final ScopeRepository scopeRepository; private final AdminManagementConverter converter; private final AdminCommonService commonService; @Transactional - public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound { + public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound, InvalidRequest { log.info("Create organization: partyId={}, ownerId={}", request.getPartyId(), request.getOwnerId()); + commonService.requireText(request.getPartyId(), "Party id"); + commonService.requireText(request.getOwnerId(), "Owner id"); + String name = commonService.requireText(request.getName(), "Organization name"); + String metadata = commonService.toStoredMetadata(request.getMetadata()); if (organizationRepository.existsByParty(request.getPartyId())) { throw new PartyAlreadyBound(); } @@ -58,8 +73,8 @@ public Organization create(CreateOrganizationRequest request) throws PartyAlread .id(UUID.randomUUID().toString()) .party(request.getPartyId()) .owner(request.getOwnerId()) - .name(request.getName()) - .metadata(commonService.toStoredMetadata(request.getMetadata())) + .name(name) + .metadata(metadata) .createdAt(LocalDateTime.now()) .status(StoredOrganizationStatus.ACTIVE.getValue()) .members(new HashSet<>()) @@ -78,13 +93,21 @@ public Organization get(String organizationId) throws OrganizationNotFound { return converter.toOrganization(commonService.findOrganization(organizationId)); } + @Transactional(readOnly = true) + public Organization getByParty(String partyId) throws OrganizationNotFound { + log.info("Get organization by party: partyId={}", partyId); + return converter.toOrganization(organizationRepository.findByParty(partyId) + .orElseThrow(OrganizationNotFound::new)); + } + @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); + Pageable pageable = PageRequest.of(0, limit + 1, Sort.by(Sort.Direction.DESC, "id")); + List entities = new ArrayList<>( + organizationRepository.findAll(specification(safeRequest), pageable).getContent()); String continuationToken = null; if (entities.size() > limit) { @@ -100,10 +123,17 @@ public ListOrganizationsResult list(ListOrganizationsRequest request) { } @Transactional - public Organization rename(String organizationId, String name) throws OrganizationNotFound { - log.info("Rename organization: organizationId={}, name={}", organizationId, name); + public Organization modify(String organizationId, ModifyOrganizationRequest request) + throws OrganizationNotFound, InvalidRequest { + log.info("Modify organization: organizationId={}, request={}", organizationId, request); + ModifyOrganizationRequest safeRequest = request == null ? new ModifyOrganizationRequest() : request; OrganizationEntity organization = commonService.findOrganization(organizationId); - organization.setName(name); + if (safeRequest.isSetName()) { + organization.setName(commonService.requireText(safeRequest.getName(), "Organization name")); + } + if (safeRequest.isSetMetadata()) { + organization.setMetadata(commonService.toStoredMetadata(safeRequest.getMetadata())); + } return converter.toOrganization(organizationRepository.save(organization)); } @@ -120,11 +150,12 @@ public Organization activate(String organizationId) throws OrganizationNotFound, } @Transactional(readOnly = true) - public OrganizationRole getRole(String organizationId, String roleId) throws OrganizationNotFound { + public OrganizationRole getRole(String organizationId, String roleId) + throws OrganizationNotFound, RoleNotFound { log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); commonService.findOrganization(organizationId); OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(OrganizationNotFound::new); + .orElseThrow(RoleNotFound::new); return converter.toOrganizationRole(role); } @@ -138,19 +169,56 @@ public List listRoles(String organizationId) throws Organizati .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); + /** + * Создаёт либо обновляет роль в каталоге ролей организации. Каталог задаёт, какие роли + * и области действия допустимы в AssignMemberRole и CreateInvitation + */ + @Transactional + public OrganizationRole setRole(String organizationId, SetOrganizationRoleRequest request) + throws OrganizationNotFound, InvalidRequest { + log.info("Set organization role: organizationId={}, request={}", organizationId, request); + if (request == null) { + throw new InvalidRequest("Request must not be null"); } - if (token != null) { - return organizationRepository.findByIdLessThanOrderByIdDesc(token, pageable); + commonService.findOrganization(organizationId); + String roleId = commonService.requireText(request.getRoleId(), "Role id"); + String name = commonService.requireText(request.getName(), "Role name"); + OrganizationRoleEntity role = organizationRoleRepository + .findByOrganizationIdAndRoleId(organizationId, roleId) + .orElseGet(() -> OrganizationRoleEntity.builder() + .id(UUID.randomUUID().toString()) + .organizationId(organizationId) + .roleId(roleId) + .build()); + role.setName(name); + role.setPossibleScopes(resolveScopes(request.getScopeIds())); + return converter.toOrganizationRole(organizationRoleRepository.save(role)); + } + + private Set resolveScopes(List scopeIds) throws InvalidRequest { + Set scopes = new LinkedHashSet<>(); + for (String scopeId : scopeIds == null ? List.of() : scopeIds) { + commonService.requireText(scopeId, "Scope id"); + scopes.add(scopeRepository.findById(scopeId) + .orElseGet(() -> scopeRepository.save(ScopeEntity.builder().id(scopeId).build()))); } - return organizationRepository.findAllByOrderByIdDesc(pageable); + return scopes; + } + + private Specification specification(ListOrganizationsRequest request) { + return (root, query, builder) -> { + List predicates = new ArrayList<>(); + if (request.isSetStatus()) { + predicates.add(builder.equal(root.get("status"), request.getStatus().name())); + } + if (request.isSetOwnerId()) { + predicates.add(builder.equal(root.get("owner"), request.getOwnerId())); + } + if (request.isSetContinuationToken()) { + predicates.add(builder.lessThan(root.get("id"), request.getContinuationToken())); + } + return builder.and(predicates.toArray(new Predicate[0])); + }; } private int organizationLimit(ListOrganizationsRequest request) { diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index c5a3e1c3..e522acd3 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -47,13 +47,13 @@ public abstract class AbstractRepositoryTest { public void setUp() throws Exception { invitationRepository.deleteAll(); memberContextRepository.deleteAll(); + organizationRoleRepository.deleteAll(); organizationRepository.deleteAll(); var members = memberRepository.findAll(); members.forEach(it -> it.getRoles().clear()); memberRepository.saveAll(members); memberRepository.deleteAll(); memberRoleRepository.deleteAll(); - organizationRoleRepository.deleteAll(); } @BeforeAll diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index ae94510d..d293b511 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -1,29 +1,20 @@ 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.orgmanagement.*; 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.entity.OrganizationRoleEntity; +import dev.vality.orgmanager.entity.ScopeEntity; 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.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; import dev.vality.orgmanager.util.JsonCodec; import org.junit.jupiter.api.BeforeEach; @@ -31,7 +22,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import tools.jackson.databind.json.JsonMapper; import java.time.LocalDateTime; @@ -43,10 +36,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; 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.anyCollection; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -63,6 +59,8 @@ class AdminManagementServiceTest { @Mock private OrganizationRoleRepository organizationRoleRepository; @Mock + private ScopeRepository scopeRepository; + @Mock private InvitationRepository invitationRepository; @Mock private InviteTokenProperties inviteTokenProperties; @@ -76,11 +74,13 @@ void setUp() { AdminManagementConverter converter = new AdminManagementConverter(); AdminCommonService commonService = new AdminCommonService( organizationRepository, + organizationRoleRepository, new JsonCodec(JsonMapper.builder().build())); service = new AdminManagementService( new AdminOrganizationService( organizationRepository, organizationRoleRepository, + scopeRepository, converter, commonService), new AdminMemberService( @@ -120,6 +120,23 @@ void shouldRejectPartyAlreadyBoundToOrganization() { verifyNoInteractions(memberRepository); } + @Test + void shouldRejectMetadataThatRestWouldFailToRead() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", "name") + .setMetadata("not a json"); + + assertThrows(InvalidRequest.class, () -> service.createOrganization(request)); + verifyNoInteractions(memberRepository); + } + + @Test + void shouldRejectBlankOrganizationName() { + CreateOrganizationRequest request = new CreateOrganizationRequest("party", "owner", " "); + + assertThrows(InvalidRequest.class, () -> service.createOrganization(request)); + verifyNoInteractions(organizationRepository); + } + @Test void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { ListOrganizationsRequest request = new ListOrganizationsRequest().setLimit(2); @@ -127,7 +144,8 @@ void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { organization("c", OrganizationStatus.active), organization("b", OrganizationStatus.active), organization("a", OrganizationStatus.active)); - when(organizationRepository.findAllByOrderByIdDesc(any(Pageable.class))).thenReturn(entities); + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(entities)); var result = service.listOrganizations(request); @@ -135,6 +153,62 @@ void shouldReturnContinuationTokenWhenMoreOrganizationsExist() { assertEquals("b", result.getContinuationToken()); } + @Test + void shouldNotReturnContinuationTokenOnLastPage() { + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(organization("a", OrganizationStatus.active)))); + + var result = service.listOrganizations(new ListOrganizationsRequest().setOwnerId("owner-a")); + + assertEquals(List.of("a"), result.getOrganizations().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldGetOrganizationByParty() throws Exception { + when(organizationRepository.findByParty("party-org")).thenReturn(Optional.of( + organization("org", OrganizationStatus.active))); + + assertEquals("org", service.getOrganizationByParty("party-org").getId()); + } + + @Test + void shouldRejectUnknownParty() { + when(organizationRepository.findByParty("party-org")).thenReturn(Optional.empty()); + + assertThrows(OrganizationNotFound.class, () -> service.getOrganizationByParty("party-org")); + } + + @Test + void shouldModifyOnlyRequestedOrganizationFields() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMetadata("{\"source\":\"rest\"}"); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var renamed = service.modifyOrganization("org", new ModifyOrganizationRequest().setName("renamed")); + + assertEquals("renamed", renamed.getName()); + assertEquals("{\"source\":\"rest\"}", renamed.getMetadata()); + + var remetadated = service.modifyOrganization( + "org", + new ModifyOrganizationRequest().setMetadata("{\"source\":\"admin\"}")); + + assertEquals("renamed", remetadated.getName()); + assertEquals("{\"source\":\"admin\"}", remetadated.getMetadata()); + } + + @Test + void shouldRejectModificationWithBrokenMetadata() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidRequest.class, () -> service.modifyOrganization( + "org", + new ModifyOrganizationRequest().setMetadata("not a json"))); + } + @Test void shouldValidateOrganizationStatusTransitions() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -148,6 +222,55 @@ void shouldValidateOrganizationStatusTransitions() throws Exception { assertEquals(OrganizationStatus.active, service.activateOrganization("org").getStatus()); } + @Test + void shouldRejectUnknownRoleWithRoleNotFound() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.empty()); + + assertThrows(RoleNotFound.class, () -> service.getOrganizationRole("org", "manager")); + } + + @Test + void shouldCreateRoleInCatalogAndThenUpdateIt() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.empty()); + when(organizationRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(scopeRepository.findById("Shop")).thenReturn(Optional.empty()); + when(scopeRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var created = service.setOrganizationRole( + "org", + new SetOrganizationRoleRequest("manager", "Manager", List.of("Shop"))); + + assertEquals("manager", created.getId()); + assertEquals("Manager", created.getName()); + assertEquals(List.of("Shop"), created.getScopeIds()); + + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + + var updated = service.setOrganizationRole( + "org", + new SetOrganizationRoleRequest("manager", "Shop manager", new ArrayList<>())); + + assertEquals("Shop manager", updated.getName()); + assertTrue(updated.getScopeIds().isEmpty()); + } + + @Test + void shouldRejectRoleWithBlankName() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidRequest.class, () -> service.setOrganizationRole( + "org", + new SetOrganizationRoleRequest("manager", " ", List.of()))); + } + @Test void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -155,8 +278,10 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { 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)); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.of(role("manager", "Manager"))); - var member = service.addMember("org", new AddMemberRequest("user").setEmail("user@example.com")); + var member = service.addMember("org", new AddMemberRequest("user", "user@example.com")); var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); service.removeMemberRole("org", "user", role.getId()); @@ -164,6 +289,67 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { assertTrue(service.getMember("org", "user").getRoles().isEmpty()); } + @Test + void shouldUpdateEmailWhenMemberIsAddedTwice() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + MemberEntity existing = MemberEntity.builder() + .id("user") + .email("old@example.com") + .roles(new HashSet<>()) + .build(); + organization.setMembers(new HashSet<>(Set.of(existing))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.findById("user")).thenReturn(Optional.of(existing)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var member = service.addMember("org", new AddMemberRequest("user", "new@example.com")); + + assertEquals("new@example.com", member.getEmail()); + assertEquals(1, organization.getMembers().size()); + } + + @Test + void shouldRejectMemberWithoutEmail() { + assertThrows(InvalidRequest.class, + () -> service.addMember("org", new AddMemberRequest().setUserId("user"))); + verifyNoInteractions(organizationRepository); + } + + @Test + void shouldRejectRoleThatIsMissingFromCatalog() { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.empty()); + + assertThrows(InvalidRequest.class, + () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager"))); + verifyNoInteractions(memberRoleRepository); + } + + @Test + void shouldRejectScopeThatRoleDoesNotAllow() { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Wallet")))); + verifyNoInteractions(memberRoleRepository); + } + @Test void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { MemberRoleEntity role = MemberRoleEntity.builder() @@ -185,28 +371,69 @@ void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { } @Test - void shouldUseEffectiveInvitationStatusForListingAndRevocation() throws Exception { + void shouldRevokeOnlyPendingInvitation() 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, + assertThrows(InvalidInvitationState.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 shouldReturnContinuationTokenWhenMoreInvitationsExist() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + invitation("newest", LocalDateTime.now().plusDays(1)), + invitation("oldest", LocalDateTime.now().plusDays(1))))); + + var result = service.listInvitations("org", new ListInvitationsRequest().setLimit(1)); + + assertEquals(List.of("newest"), result.getInvitations().stream().map(it -> it.getId()).toList()); + assertEquals("newest", result.getContinuationToken()); + } + + @Test + void shouldReadNextInvitationsPageFromCursorInvitation() throws Exception { + InvitationEntity cursor = invitation("newest", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("newest", "org")).thenReturn(Optional.of(cursor)); + when(invitationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(invitation("oldest", LocalDateTime.now().plusDays(1))))); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setLimit(1).setContinuationToken("newest")); + + assertEquals(List.of("oldest"), result.getInvitations().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnEmptyPageForUnknownInvitationContinuationToken() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("gone", "org")).thenReturn(Optional.empty()); + + var result = service.listInvitations( + "org", + new ListInvitationsRequest().setContinuationToken("gone")); + + assertTrue(result.getInvitations().isEmpty()); + verify(invitationRepository, never()).findAll(any(Specification.class), any(Pageable.class)); + } + @Test void shouldCreateInvitationAndSendMail() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -222,6 +449,20 @@ void shouldCreateInvitationAndSendMail() throws Exception { verify(mailMessageSender).send(any(String.class), eq("user@example.com")); } + @Test + void shouldRejectInvitationWithRoleOutsideCatalog() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.empty()); + CreateInvitationRequest request = new CreateInvitationRequest( + "user@example.com", + List.of(new RoleAssignment("manager"))); + + assertThrows(InvalidRequest.class, () -> service.createInvitation("org", request)); + verifyNoInteractions(mailMessageSender); + } + @Test void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -232,37 +473,73 @@ void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception 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( + when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))) + .thenReturn(List.of("user-1", "user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).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"); + var result = service.listMembers("org", new ListMembersRequest()); + var members = result.getMembers(); 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()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnContinuationTokenWhenMoreMembersExist() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))) + .thenReturn(List.of("user-1", "user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( + memberRow("user-1", "user-1@example.com", null, null))); + + var result = service.listMembers("org", new ListMembersRequest().setLimit(1)); + + assertEquals(List.of("user-1"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertEquals("user-1", result.getContinuationToken()); + } + + @Test + void shouldReadNextMembersPageByContinuationToken() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), eq("user-1"), any(Pageable.class))) + .thenReturn(List.of("user-2")); + when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( + memberRow("user-2", "user-2@example.com", null, null))); + + var result = service.listMembers( + "org", + new ListMembersRequest().setLimit(1).setContinuationToken("user-1")); + + assertEquals(List.of("user-2"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldReturnEmptyPageWithoutQueryingMemberRows() throws Exception { + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))).thenReturn(List.of()); + + var result = service.listMembers("org", new ListMembersRequest()); + + assertTrue(result.getMembers().isEmpty()); + assertNull(result.getContinuationToken()); } @Test void shouldRejectListingMembersOfUnknownOrganization() { when(organizationRepository.existsById("org")).thenReturn(false); - assertThrows(OrganizationNotFound.class, () -> service.listMembers("org")); + assertThrows(OrganizationNotFound.class, () -> service.listMembers("org", new ListMembersRequest())); verifyNoInteractions(memberRepository); } @@ -305,6 +582,20 @@ public String getResourceId() { }; } + private OrganizationRoleEntity role(String roleId, String name, String... scopeIds) { + Set scopes = new HashSet<>(); + for (String scopeId : scopeIds) { + scopes.add(ScopeEntity.builder().id(scopeId).build()); + } + return OrganizationRoleEntity.builder() + .id("catalog-" + roleId) + .organizationId("org") + .roleId(roleId) + .name(name) + .possibleScopes(scopes) + .build(); + } + private OrganizationEntity organization(String id, OrganizationStatus status) { return OrganizationEntity.builder() .id(id) From 69a4646d30e1b5e544f5b8a6bf88010b796a1f27 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 11:11:58 +0400 Subject: [PATCH 10/19] fix --- .../java/dev/vality/orgmanager/repository/MemberRepository.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index 387e12e6..b7646d73 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -61,8 +61,6 @@ public interface MemberRepository extends JpaRepository { /** * То же, что {@link #getOrgMemberList(String)}, но джойн ролей внешний и выборка ограничена * заданными участниками: участник без активных ролей в организации тоже попадает в выдачу. - * Административный контракт умеет добавлять участника без роли (AddMember), и такой участник - * не должен пропадать из списка. У строк для участника без ролей memberRoleId == null. */ @NativeQuery("SELECT m.id, " + " m.email, " + From 091d91afb2afab8dac33b779ea70b44a1302a98b Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 12:43:44 +0400 Subject: [PATCH 11/19] fix --- .../orgmanager/service/AdminCommonService.java | 10 ++++++++++ .../orgmanager/service/AdminInvitationService.java | 13 ++----------- .../orgmanager/service/AdminMemberService.java | 13 ++----------- .../service/AdminOrganizationService.java | 13 ++----------- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java index 298f677f..f8edae19 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -24,6 +24,9 @@ @RequiredArgsConstructor public class AdminCommonService { + public static final int DEFAULT_PAGE_LIMIT = 20; + public static final int MAX_PAGE_LIMIT = 1000; + private final OrganizationRepository organizationRepository; private final OrganizationRoleRepository organizationRoleRepository; private final JsonCodec jsonCodec; @@ -92,4 +95,11 @@ public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, public static Collection collectionOrEmpty(Collection collection) { return collection == null ? List.of() : collection; } + + public static int pageLimit(int requested) { + if (requested <= 0) { + return DEFAULT_PAGE_LIMIT; + } + return Math.min(requested, MAX_PAGE_LIMIT); + } } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 1f8d1b99..78a210fa 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -39,6 +39,7 @@ import java.util.UUID; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; /** * Приглашения в организации в административном контракте. @@ -48,9 +49,6 @@ @RequiredArgsConstructor public class AdminInvitationService { - static final int DEFAULT_INVITATION_LIMIT = 20; - static final int MAX_INVITATION_LIMIT = 1000; - private final InvitationRepository invitationRepository; private final InviteTokenProperties inviteTokenProperties; private final MailMessageSender mailMessageSender; @@ -101,7 +99,7 @@ public ListInvitationsResult list(String organizationId, ListInvitationsRequest log.info("List invitations: organizationId={}, request={}", organizationId, request); commonService.findOrganization(organizationId); ListInvitationsRequest safeRequest = request == null ? new ListInvitationsRequest() : request; - int limit = invitationLimit(safeRequest); + int limit = pageLimit(safeRequest.getLimit()); InvitationEntity cursor = null; if (safeRequest.isSetContinuationToken()) { @@ -201,13 +199,6 @@ private Predicate statusPredicate( }; } - private int invitationLimit(ListInvitationsRequest request) { - if (!request.isSetLimit() || request.getLimit() <= 0) { - return DEFAULT_INVITATION_LIMIT; - } - return Math.min(request.getLimit(), MAX_INVITATION_LIMIT); - } - 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/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index 2a4c73ea..1dc00d4d 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -30,6 +30,7 @@ import java.util.Set; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; /** * Участники организаций и их роли в административном контракте. @@ -39,9 +40,6 @@ @RequiredArgsConstructor public class AdminMemberService { - static final int DEFAULT_MEMBER_LIMIT = 20; - static final int MAX_MEMBER_LIMIT = 1000; - private final OrganizationRepository organizationRepository; private final MemberRepository memberRepository; private final MemberRoleRepository memberRoleRepository; @@ -62,7 +60,7 @@ public ListMembersResult list(String organizationId, ListMembersRequest request) throw new OrganizationNotFound(); } ListMembersRequest safeRequest = request == null ? new ListMembersRequest() : request; - int limit = memberLimit(safeRequest); + int limit = pageLimit(safeRequest.getLimit()); Pageable pageable = PageRequest.ofSize(limit + 1); String token = safeRequest.getContinuationToken(); List memberIds = new ArrayList<>(token == null @@ -173,13 +171,6 @@ public void removeRole(String organizationId, String userId, String memberRoleId memberRepository.save(member); } - private int memberLimit(ListMembersRequest request) { - if (!request.isSetLimit() || request.getLimit() <= 0) { - return DEFAULT_MEMBER_LIMIT; - } - return Math.min(request.getLimit(), MAX_MEMBER_LIMIT); - } - private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { return collectionOrEmpty(organization.getMembers()).stream() .filter(member -> member.getId().equals(userId)) diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index 58d7b129..bd315fcb 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -41,6 +41,7 @@ import java.util.UUID; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; /** * Организации и их роли в административном контракте. @@ -50,9 +51,6 @@ @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 ScopeRepository scopeRepository; @@ -104,7 +102,7 @@ public Organization getByParty(String partyId) throws OrganizationNotFound { public ListOrganizationsResult list(ListOrganizationsRequest request) { log.info("List organizations: request={}", request); ListOrganizationsRequest safeRequest = request == null ? new ListOrganizationsRequest() : request; - int limit = organizationLimit(safeRequest); + int limit = pageLimit(safeRequest.getLimit()); Pageable pageable = PageRequest.of(0, limit + 1, Sort.by(Sort.Direction.DESC, "id")); List entities = new ArrayList<>( organizationRepository.findAll(specification(safeRequest), pageable).getContent()); @@ -221,13 +219,6 @@ private Specification specification(ListOrganizationsRequest }; } - 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, From 2c92ef382ab43a8d536f72e39657d38316756b54 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 13:08:36 +0400 Subject: [PATCH 12/19] fix --- .../repository/MemberRepository.java | 15 ++------ .../service/AdminInvitationService.java | 32 ++++++++--------- .../service/AdminMemberService.java | 34 ++++++++----------- .../service/AdminOrganizationService.java | 22 +++++------- .../service/AdminManagementServiceTest.java | 9 ++--- 5 files changed, 44 insertions(+), 68 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index b7646d73..cc254610 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -36,25 +36,14 @@ public interface MemberRepository extends JpaRepository { /** * Страница идентификаторов участников организации, отсортированная по возрастанию id. + * continuationToken == null — первая страница, иначе участники строго после указанного id. */ @NativeQuery("SELECT m.id " + " FROM org_manager.member_to_organization mto " + " JOIN org_manager.member m " + " ON m.id = mto.member_id " + " WHERE mto.organization_id = ?1 " + - " ORDER BY m.id ") - List getOrgMemberIds(String orgId, Pageable pageable); - - /** - * То же, что {@link #getOrgMemberIds(String, Pageable)}, но со сдвигом по continuation token: - * возвращаются участники, идущие строго после указанного id. - */ - @NativeQuery("SELECT m.id " + - " FROM org_manager.member_to_organization mto " + - " JOIN org_manager.member m " + - " ON m.id = mto.member_id " + - " WHERE mto.organization_id = ?1 " + - " AND m.id > ?2 " + + " AND (CAST(?2 AS VARCHAR) IS NULL OR m.id > ?2) " + " ORDER BY m.id ") List getOrgMemberIds(String orgId, String continuationToken, Pageable pageable); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 78a210fa..6cd39365 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -18,6 +18,7 @@ import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.entity.StoredInviteeContactType; import dev.vality.orgmanager.repository.InvitationRepository; +import dev.vality.orgmanager.service.dto.AdminPage; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; @@ -35,11 +36,13 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.UUID; import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; /** * Приглашения в организации в административном контракте. @@ -98,15 +101,14 @@ public ListInvitationsResult list(String organizationId, ListInvitationsRequest throws OrganizationNotFound { log.info("List invitations: organizationId={}, request={}", organizationId, request); commonService.findOrganization(organizationId); - ListInvitationsRequest safeRequest = request == null ? new ListInvitationsRequest() : request; + ListInvitationsRequest safeRequest = requireNonNullElseGet(request, ListInvitationsRequest::new); int limit = pageLimit(safeRequest.getLimit()); - InvitationEntity cursor = null; + Optional cursor = Optional.empty(); if (safeRequest.isSetContinuationToken()) { cursor = invitationRepository - .findByIdAndOrganizationId(safeRequest.getContinuationToken(), organizationId) - .orElse(null); - if (cursor == null) { + .findByIdAndOrganizationId(safeRequest.getContinuationToken(), organizationId); + if (cursor.isEmpty()) { return new ListInvitationsResult(List.of()); } } @@ -117,20 +119,14 @@ public ListInvitationsResult list(String organizationId, ListInvitationsRequest organizationId, safeRequest.isSetStatus() ? safeRequest.getStatus() : null, LocalDateTime.now(), - cursor); - List page = new ArrayList<>( - invitationRepository.findAll(specification, pageable).getContent()); - - String continuationToken = null; - if (page.size() > limit) { - page = new ArrayList<>(page.subList(0, limit)); - continuationToken = page.get(page.size() - 1).getId(); - } + cursor.orElse(null)); + AdminPage page = AdminPage.of( + invitationRepository.findAll(specification, pageable).getContent(), + limit, + InvitationEntity::getId); ListInvitationsResult result = new ListInvitationsResult( - page.stream().map(converter::toInvitation).toList()); - if (continuationToken != null) { - result.setContinuationToken(continuationToken); - } + page.items().stream().map(converter::toInvitation).toList()); + page.continuationToken().ifPresent(result::setContinuationToken); return result; } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index 1dc00d4d..f8a50fca 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -17,10 +17,10 @@ import dev.vality.orgmanager.repository.MemberRepository; import dev.vality.orgmanager.repository.MemberRoleRepository; import dev.vality.orgmanager.repository.OrganizationRepository; +import dev.vality.orgmanager.service.dto.AdminPage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +31,8 @@ import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; +import static java.util.function.Function.identity; /** * Участники организаций и их роли в административном контракте. @@ -59,26 +61,18 @@ public ListMembersResult list(String organizationId, ListMembersRequest request) if (!organizationRepository.existsById(organizationId)) { throw new OrganizationNotFound(); } - ListMembersRequest safeRequest = request == null ? new ListMembersRequest() : request; + ListMembersRequest safeRequest = requireNonNullElseGet(request, ListMembersRequest::new); int limit = pageLimit(safeRequest.getLimit()); - Pageable pageable = PageRequest.ofSize(limit + 1); - String token = safeRequest.getContinuationToken(); - List memberIds = new ArrayList<>(token == null - ? memberRepository.getOrgMemberIds(organizationId, pageable) - : memberRepository.getOrgMemberIds(organizationId, token, pageable)); - - String continuationToken = null; - if (memberIds.size() > limit) { - memberIds = new ArrayList<>(memberIds.subList(0, limit)); - continuationToken = memberIds.get(memberIds.size() - 1); - } - List members = memberIds.isEmpty() - ? List.of() - : converter.toMembers(memberRepository.getOrgMemberListWithRoles(organizationId, memberIds)); - ListMembersResult result = new ListMembersResult(members); - if (continuationToken != null) { - result.setContinuationToken(continuationToken); - } + AdminPage page = AdminPage.of( + memberRepository.getOrgMemberIds( + organizationId, + safeRequest.getContinuationToken(), + PageRequest.ofSize(limit + 1)), + limit, + identity()); + ListMembersResult result = new ListMembersResult(converter.toMembers( + memberRepository.getOrgMemberListWithRoles(organizationId, page.items()))); + page.continuationToken().ifPresent(result::setContinuationToken); return result; } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index bd315fcb..fd108b0c 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -20,6 +20,7 @@ import dev.vality.orgmanager.repository.OrganizationRepository; import dev.vality.orgmanager.repository.OrganizationRoleRepository; import dev.vality.orgmanager.repository.ScopeRepository; +import dev.vality.orgmanager.service.dto.AdminPage; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,7 @@ import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; /** * Организации и их роли в административном контракте. @@ -101,22 +103,16 @@ public Organization getByParty(String partyId) throws OrganizationNotFound { @Transactional(readOnly = true) public ListOrganizationsResult list(ListOrganizationsRequest request) { log.info("List organizations: request={}", request); - ListOrganizationsRequest safeRequest = request == null ? new ListOrganizationsRequest() : request; + ListOrganizationsRequest safeRequest = requireNonNullElseGet(request, ListOrganizationsRequest::new); int limit = pageLimit(safeRequest.getLimit()); Pageable pageable = PageRequest.of(0, limit + 1, Sort.by(Sort.Direction.DESC, "id")); - List entities = new ArrayList<>( - organizationRepository.findAll(specification(safeRequest), pageable).getContent()); - - String continuationToken = null; - if (entities.size() > limit) { - entities = new ArrayList<>(entities.subList(0, limit)); - continuationToken = entities.get(entities.size() - 1).getId(); - } + AdminPage page = AdminPage.of( + organizationRepository.findAll(specification(safeRequest), pageable).getContent(), + limit, + OrganizationEntity::getId); ListOrganizationsResult result = new ListOrganizationsResult( - entities.stream().map(converter::toOrganization).toList()); - if (continuationToken != null) { - result.setContinuationToken(continuationToken); - } + page.items().stream().map(converter::toOrganization).toList()); + page.continuationToken().ifPresent(result::setContinuationToken); return result; } diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index d293b511..4d914611 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -476,7 +477,7 @@ void shouldTreatMetadataWrittenByRestAsAbsentWhenItIsJsonNull() throws Exception @Test void shouldListMembersIncludingOnesWithoutRoles() throws Exception { when(organizationRepository.existsById("org")).thenReturn(true); - when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))) + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))) .thenReturn(List.of("user-1", "user-2")); when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( memberRow("user-1", "user-1@example.com", "role-1", "manager"), @@ -497,7 +498,7 @@ void shouldListMembersIncludingOnesWithoutRoles() throws Exception { @Test void shouldReturnContinuationTokenWhenMoreMembersExist() throws Exception { when(organizationRepository.existsById("org")).thenReturn(true); - when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))) + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))) .thenReturn(List.of("user-1", "user-2")); when(memberRepository.getOrgMemberListWithRoles(eq("org"), anyCollection())).thenReturn(List.of( memberRow("user-1", "user-1@example.com", null, null))); @@ -525,9 +526,9 @@ void shouldReadNextMembersPageByContinuationToken() throws Exception { } @Test - void shouldReturnEmptyPageWithoutQueryingMemberRows() throws Exception { + void shouldReturnEmptyPageWhenOrganizationHasNoMembers() throws Exception { when(organizationRepository.existsById("org")).thenReturn(true); - when(memberRepository.getOrgMemberIds(eq("org"), any(Pageable.class))).thenReturn(List.of()); + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))).thenReturn(List.of()); var result = service.listMembers("org", new ListMembersRequest()); From 93332bc94ef79f0ea0fe6f18a42e204485a172dc Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 13:16:31 +0400 Subject: [PATCH 13/19] fix --- .../orgmanager/service/dto/AdminPage.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java diff --git a/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java new file mode 100644 index 00000000..8fde4304 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java @@ -0,0 +1,22 @@ +package dev.vality.orgmanager.service.dto; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Страница выдачи, собранная приёмом «выбрать limit + 1»: лишняя строка означает, что за + * страницей есть продолжение, а последняя оставленная строка даёт continuation token. + * Механика одинакова для всех постраничных методов административного контракта, поэтому + * живёт здесь, а не в каждом сервисе. + */ +public record AdminPage(List items, Optional continuationToken) { + + public static AdminPage of(List fetched, int limit, Function tokenOf) { + if (fetched.size() <= limit) { + return new AdminPage<>(List.copyOf(fetched), Optional.empty()); + } + List items = List.copyOf(fetched.subList(0, limit)); + return new AdminPage<>(items, Optional.of(tokenOf.apply(items.get(items.size() - 1)))); + } +} From b8e67d86ca25768fc759f55781be9a51d72e466b Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Wed, 9 Sep 2026 13:17:29 +0400 Subject: [PATCH 14/19] fix --- src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java index 8fde4304..83eace2f 100644 --- a/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java +++ b/src/main/java/dev/vality/orgmanager/service/dto/AdminPage.java @@ -7,8 +7,7 @@ /** * Страница выдачи, собранная приёмом «выбрать limit + 1»: лишняя строка означает, что за * страницей есть продолжение, а последняя оставленная строка даёт continuation token. - * Механика одинакова для всех постраничных методов административного контракта, поэтому - * живёт здесь, а не в каждом сервисе. + * Механика одинакова для всех постраничных методов административного контракта. */ public record AdminPage(List items, Optional continuationToken) { From 5b241ba552ecf941089c083baeaac4209d8a4865 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Fri, 11 Sep 2026 00:30:44 +0400 Subject: [PATCH 15/19] Add user methods to Admin Management --- pom.xml | 2 +- .../converter/AdminManagementConverter.java | 34 ++++-- .../repository/MemberRepository.java | 12 ++ .../service/AdminManagementService.java | 11 ++ .../orgmanager/service/AdminUserService.java | 58 ++++++++++ .../orgmanager/service/dto/UserDto.java | 9 ++ .../service/AdminManagementServiceTest.java | 103 +++++++++++++++++- 7 files changed, 210 insertions(+), 19 deletions(-) create mode 100644 src/main/java/dev/vality/orgmanager/service/AdminUserService.java create mode 100644 src/main/java/dev/vality/orgmanager/service/dto/UserDto.java diff --git a/pom.xml b/pom.xml index 86a4b895..a22f3380 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.21-23aa5d8 + 1.22-4a6c2e7 dev.vality diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java index c48adffd..8b9f2ffa 100644 --- a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -9,6 +9,7 @@ import dev.vality.orgmanagement.OrganizationStatus; import dev.vality.orgmanagement.RoleAssignment; import dev.vality.orgmanagement.RoleScope; +import dev.vality.orgmanagement.User; import dev.vality.orgmanager.entity.InvitationEntity; import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.entity.MemberRoleEntity; @@ -16,6 +17,7 @@ import dev.vality.orgmanager.entity.OrganizationRoleEntity; import dev.vality.orgmanager.entity.StoredInvitationStatus; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import dev.vality.orgmanager.service.dto.UserDto; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -49,6 +51,22 @@ public Organization toOrganization(OrganizationEntity entity) { return organization; } + public User toUser(MemberEntity entity) { + return toUser(entity.getId(), entity.getEmail()); + } + + public User toUser(UserDto row) { + return toUser(row.getId(), row.getEmail()); + } + + private User toUser(String id, String email) { + User user = new User(id); + if (email != null) { + user.setEmail(email); + } + return user; + } + public Member toMember(MemberEntity entity, String organizationId) { List roles = collectionOrEmpty(entity.getRoles()).stream() .filter(MemberRoleEntity::isActive) @@ -56,11 +74,7 @@ public Member toMember(MemberEntity entity, String organizationId) { .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; + return new Member(toUser(entity), roles); } /** @@ -70,13 +84,9 @@ public Member toMember(MemberEntity entity, String organizationId) { 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; - }); + Member member = members.computeIfAbsent( + row.getId(), + id -> new Member(toUser(id, row.getEmail()), new ArrayList<>())); if (row.getMemberRoleId() != null) { member.getRoles().add(toMemberRole(row)); } diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index cc254610..64f08d97 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -2,6 +2,7 @@ import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import dev.vality.orgmanager.service.dto.UserDto; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.NativeQuery; @@ -72,6 +73,17 @@ public interface MemberRepository extends JpaRepository { " ORDER BY m.id, mr.id ") List getOrgMemberListWithRoles(String orgId, Collection memberIds); + /** + * Страница пользователей по возрастанию id + */ + @NativeQuery("SELECT m.id, " + + " m.email " + + " FROM org_manager.member m " + + " WHERE (CAST(?1 AS VARCHAR) IS NULL OR m.id > ?1) " + + " AND (CAST(?2 AS VARCHAR) IS NULL OR m.email = ?2) " + + " ORDER BY m.id ") + List getUserPage(String continuationToken, String email, Pageable pageable); + boolean existsById(String id); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java index d129124b..62ad8526 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -11,6 +11,7 @@ public class AdminManagementService implements AdminManagementSrv.Iface { private final AdminOrganizationService adminOrganizationService; + private final AdminUserService adminUserService; private final AdminMemberService adminMemberService; private final AdminInvitationService adminInvitationService; @@ -53,6 +54,16 @@ public Organization activateOrganization(String organizationId) return adminOrganizationService.activate(organizationId); } + @Override + public ListUsersResult listUsers(ListUsersRequest request) { + return adminUserService.list(request); + } + + @Override + public User getUser(String userId) throws UnknownUser { + return adminUserService.get(userId); + } + @Override public OrganizationRole getOrganizationRole(String organizationId, String roleId) throws OrganizationNotFound, RoleNotFound { diff --git a/src/main/java/dev/vality/orgmanager/service/AdminUserService.java b/src/main/java/dev/vality/orgmanager/service/AdminUserService.java new file mode 100644 index 00000000..ef356292 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/AdminUserService.java @@ -0,0 +1,58 @@ +package dev.vality.orgmanager.service; + +import dev.vality.orgmanagement.ListUsersRequest; +import dev.vality.orgmanagement.ListUsersResult; +import dev.vality.orgmanagement.UnknownUser; +import dev.vality.orgmanagement.User; +import dev.vality.orgmanager.converter.AdminManagementConverter; +import dev.vality.orgmanager.repository.MemberRepository; +import dev.vality.orgmanager.service.dto.AdminPage; +import dev.vality.orgmanager.service.dto.UserDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; +import static java.util.Objects.requireNonNullElseGet; + +/** Пользователи в административном контракте: страница пользователей и чтение по идентификатору. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final MemberRepository memberRepository; + private final AdminManagementConverter converter; + + @Transactional(readOnly = true) + public ListUsersResult list(ListUsersRequest request) { + log.info("List users: request={}", request); + ListUsersRequest safeRequest = requireNonNullElseGet(request, ListUsersRequest::new); + int limit = pageLimit(safeRequest.getLimit()); + AdminPage page = AdminPage.of( + memberRepository.getUserPage( + safeRequest.getContinuationToken(), + safeRequest.getEmail(), + PageRequest.ofSize(limit + 1)), + limit, + UserDto::getId); + ListUsersResult result = new ListUsersResult(page.items().stream() + .map(converter::toUser) + .toList()); + page.continuationToken().ifPresent(result::setContinuationToken); + return result; + } + + @Transactional(readOnly = true) + public User get(String userId) throws UnknownUser { + log.info("Get user: userId={}", userId); + if (userId == null || userId.isBlank()) { + throw new UnknownUser(); + } + return memberRepository.findById(userId) + .map(converter::toUser) + .orElseThrow(UnknownUser::new); + } +} diff --git a/src/main/java/dev/vality/orgmanager/service/dto/UserDto.java b/src/main/java/dev/vality/orgmanager/service/dto/UserDto.java new file mode 100644 index 00000000..6394dec4 --- /dev/null +++ b/src/main/java/dev/vality/orgmanager/service/dto/UserDto.java @@ -0,0 +1,9 @@ +package dev.vality.orgmanager.service.dto; + +public interface UserDto { + + String getId(); + + String getEmail(); + +} diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index 4d914611..e0e03f90 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -16,6 +16,7 @@ import dev.vality.orgmanager.repository.OrganizationRoleRepository; import dev.vality.orgmanager.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; +import dev.vality.orgmanager.service.dto.UserDto; import dev.vality.orgmanager.util.JsonCodec; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -84,6 +85,7 @@ void setUp() { scopeRepository, converter, commonService), + new AdminUserService(memberRepository, converter), new AdminMemberService( organizationRepository, memberRepository, @@ -286,7 +288,8 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); service.removeMemberRole("org", "user", role.getId()); - assertEquals("user@example.com", member.getEmail()); + assertEquals("user", member.getUser().getId()); + assertEquals("user@example.com", member.getUser().getEmail()); assertTrue(service.getMember("org", "user").getRoles().isEmpty()); } @@ -306,7 +309,7 @@ void shouldUpdateEmailWhenMemberIsAddedTwice() throws Exception { var member = service.addMember("org", new AddMemberRequest("user", "new@example.com")); - assertEquals("new@example.com", member.getEmail()); + assertEquals("new@example.com", member.getUser().getEmail()); assertEquals(1, organization.getMembers().size()); } @@ -487,11 +490,11 @@ void shouldListMembersIncludingOnesWithoutRoles() throws Exception { var result = service.listMembers("org", new ListMembersRequest()); var members = result.getMembers(); - assertEquals(List.of("user-1", "user-2"), members.stream().map(it -> it.getId()).toList()); + assertEquals(List.of("user-1", "user-2"), members.stream().map(it -> it.getUser().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()); + assertEquals("user-2@example.com", members.get(1).getUser().getEmail()); assertFalse(result.isSetContinuationToken()); } @@ -505,7 +508,7 @@ void shouldReturnContinuationTokenWhenMoreMembersExist() throws Exception { var result = service.listMembers("org", new ListMembersRequest().setLimit(1)); - assertEquals(List.of("user-1"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertEquals(List.of("user-1"), result.getMembers().stream().map(it -> it.getUser().getId()).toList()); assertEquals("user-1", result.getContinuationToken()); } @@ -521,7 +524,7 @@ void shouldReadNextMembersPageByContinuationToken() throws Exception { "org", new ListMembersRequest().setLimit(1).setContinuationToken("user-1")); - assertEquals(List.of("user-2"), result.getMembers().stream().map(it -> it.getId()).toList()); + assertEquals(List.of("user-2"), result.getMembers().stream().map(it -> it.getUser().getId()).toList()); assertFalse(result.isSetContinuationToken()); } @@ -544,6 +547,94 @@ void shouldRejectListingMembersOfUnknownOrganization() { verifyNoInteractions(memberRepository); } + @Test + void shouldListUsersWithoutOrganizationContext() throws Exception { + when(memberRepository.getUserPage(isNull(), isNull(), any(Pageable.class))).thenReturn(List.of( + userRow("user-1", "user-1@example.com"), + userRow("user-2", null))); + + var result = service.listUsers(new ListUsersRequest()); + + assertEquals(List.of("user-1", "user-2"), result.getUsers().stream().map(User::getId).toList()); + assertEquals("user-1@example.com", result.getUsers().get(0).getEmail()); + assertFalse(result.getUsers().get(1).isSetEmail()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldPassEmailFilterToQuery() throws Exception { + when(memberRepository.getUserPage(isNull(), eq("user-1@example.com"), any(Pageable.class))) + .thenReturn(List.of(userRow("user-1", "user-1@example.com"))); + + var result = service.listUsers(new ListUsersRequest().setEmail("user-1@example.com")); + + assertEquals(List.of("user-1"), result.getUsers().stream().map(User::getId).toList()); + } + + @Test + void shouldReturnContinuationTokenWhenMoreUsersExist() throws Exception { + when(memberRepository.getUserPage(isNull(), isNull(), any(Pageable.class))).thenReturn(List.of( + userRow("user-1", "user-1@example.com"), + userRow("user-2", "user-2@example.com"))); + + var result = service.listUsers(new ListUsersRequest().setLimit(1)); + + assertEquals(List.of("user-1"), result.getUsers().stream().map(User::getId).toList()); + assertEquals("user-1", result.getContinuationToken()); + } + + @Test + void shouldReadNextUsersPageByContinuationToken() throws Exception { + when(memberRepository.getUserPage(eq("user-1"), isNull(), any(Pageable.class))) + .thenReturn(List.of(userRow("user-2", "user-2@example.com"))); + + var result = service.listUsers(new ListUsersRequest().setLimit(1).setContinuationToken("user-1")); + + assertEquals(List.of("user-2"), result.getUsers().stream().map(User::getId).toList()); + assertFalse(result.isSetContinuationToken()); + } + + @Test + void shouldGetUserById() throws Exception { + when(memberRepository.findById("user")).thenReturn(Optional.of(MemberEntity.builder() + .id("user") + .email("user@example.com") + .roles(new HashSet<>()) + .build())); + + var user = service.getUser("user"); + + assertEquals("user", user.getId()); + assertEquals("user@example.com", user.getEmail()); + } + + @Test + void shouldRejectUnknownUser() { + when(memberRepository.findById("user")).thenReturn(Optional.empty()); + + assertThrows(UnknownUser.class, () -> service.getUser("user")); + } + + @Test + void shouldRejectBlankUserIdWithoutQuery() { + assertThrows(UnknownUser.class, () -> service.getUser(" ")); + verifyNoInteractions(memberRepository); + } + + private UserDto userRow(String userId, String email) { + return new UserDto() { + @Override + public String getId() { + return userId; + } + + @Override + public String getEmail() { + return email; + } + }; + } + private MemberWithRoleDto memberRow(String memberId, String email, String roleId, String role) { return new MemberWithRoleDto() { @Override From 2aad10964e68e445a0b620594bebb96db6d80e5d Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Fri, 11 Sep 2026 12:52:49 +0400 Subject: [PATCH 16/19] lower case --- .../java/dev/vality/orgmanager/repository/MemberRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index 64f08d97..1a9651a5 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -80,7 +80,7 @@ public interface MemberRepository extends JpaRepository { " m.email " + " FROM org_manager.member m " + " WHERE (CAST(?1 AS VARCHAR) IS NULL OR m.id > ?1) " + - " AND (CAST(?2 AS VARCHAR) IS NULL OR m.email = ?2) " + + " AND (CAST(?2 AS VARCHAR) IS NULL OR LOWER(m.email) = LOWER(CAST(?2 AS VARCHAR))) " + " ORDER BY m.id ") List getUserPage(String continuationToken, String email, Pageable pageable); From 324ce5059536e3f23d18d4bb9f35d864db5f91c9 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Fri, 11 Sep 2026 17:43:16 +0400 Subject: [PATCH 17/19] add more tests --- .../service/AdminManagementServiceTest.java | 273 +++++++++++++++++- 1 file changed, 272 insertions(+), 1 deletion(-) diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index e0e03f90..86d30d00 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -21,10 +21,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import tools.jackson.databind.json.JsonMapper; @@ -35,6 +37,8 @@ import java.util.Optional; import java.util.Set; +import static dev.vality.orgmanager.service.AdminCommonService.DEFAULT_PAGE_LIMIT; +import static dev.vality.orgmanager.service.AdminCommonService.MAX_PAGE_LIMIT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -621,6 +625,269 @@ void shouldRejectBlankUserIdWithoutQuery() { verifyNoInteractions(memberRepository); } + + @Test + void shouldRejectUnknownOrganizationForEveryOrganizationScopedMethod() { + when(organizationRepository.findById("gone")).thenReturn(Optional.empty()); + when(organizationRepository.existsById("gone")).thenReturn(false); + + assertThrows(OrganizationNotFound.class, () -> service.getOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.modifyOrganization( + "gone", + new ModifyOrganizationRequest().setName("renamed"))); + assertThrows(OrganizationNotFound.class, () -> service.deactivateOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.activateOrganization("gone")); + assertThrows(OrganizationNotFound.class, () -> service.getOrganizationRole("gone", "manager")); + assertThrows(OrganizationNotFound.class, () -> service.listOrganizationRoles("gone")); + assertThrows(OrganizationNotFound.class, () -> service.setOrganizationRole( + "gone", + new SetOrganizationRoleRequest("manager", "Manager", new ArrayList<>()))); + assertThrows(OrganizationNotFound.class, () -> service.getMember("gone", "user")); + assertThrows(OrganizationNotFound.class, () -> service.listMembers("gone", new ListMembersRequest())); + assertThrows(OrganizationNotFound.class, () -> service.addMember( + "gone", + new AddMemberRequest("user", "user@example.com"))); + assertThrows(OrganizationNotFound.class, () -> service.removeMember("gone", "user")); + assertThrows(OrganizationNotFound.class, () -> service.assignMemberRole( + "gone", + "user", + new AssignMemberRoleRequest("manager"))); + assertThrows(OrganizationNotFound.class, () -> service.removeMemberRole("gone", "user", "role")); + assertThrows(OrganizationNotFound.class, () -> service.createInvitation( + "gone", + new CreateInvitationRequest("user@example.com", new ArrayList<>()))); + assertThrows(OrganizationNotFound.class, () -> service.getInvitation("gone", "invitation")); + assertThrows(OrganizationNotFound.class, + () -> service.listInvitations("gone", new ListInvitationsRequest())); + assertThrows(OrganizationNotFound.class, () -> service.revokeInvitation( + "gone", + "invitation", + new RevokeInvitationRequest("obsolete"))); + + verifyNoInteractions(memberRepository, memberRoleRepository, invitationRepository, mailMessageSender); + } + + @Test + void shouldRejectUnknownMemberForEveryMemberScopedMethod() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(MemberNotFound.class, () -> service.getMember("org", "ghost")); + assertThrows(MemberNotFound.class, () -> service.removeMember("org", "ghost")); + assertThrows(MemberNotFound.class, () -> service.assignMemberRole( + "org", + "ghost", + new AssignMemberRoleRequest("manager"))); + assertThrows(MemberNotFound.class, () -> service.removeMemberRole("org", "ghost", "role")); + + verifyNoInteractions(memberRepository, memberRoleRepository, organizationRoleRepository); + } + + /** + * Удаление участника гасит только его роли в этой организации: роли в других + * организациях остаются активными, сама запись пользователя не удаляется. + */ + @Test + void shouldRemoveMemberAndDeactivateOnlyItsRolesInThisOrganization() throws Exception { + MemberRoleEntity here = MemberRoleEntity.builder() + .id("role-here") + .organizationId("org") + .roleId("manager") + .active(true) + .build(); + MemberRoleEntity elsewhere = MemberRoleEntity.builder() + .id("role-elsewhere") + .organizationId("another-org") + .roleId("manager") + .active(true) + .build(); + MemberEntity member = MemberEntity.builder() + .id("user") + .email("user@example.com") + .roles(new HashSet<>(Set.of(here, elsewhere))) + .build(); + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(member))); + 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)); + + service.removeMember("org", "user"); + + assertFalse(here.isActive()); + assertTrue(elsewhere.isActive()); + assertEquals(Set.of(elsewhere), member.getRoles()); + assertTrue(organization.getMembers().isEmpty()); + verify(memberRoleRepository).saveAll(List.of(here)); + assertThrows(MemberNotFound.class, () -> service.getMember("org", "user")); + } + + @Test + void shouldGetInvitationWithinItsOrganization() throws Exception { + InvitationEntity pending = invitation("pending", LocalDateTime.now().plusDays(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("pending", "org")).thenReturn(Optional.of(pending)); + + var result = service.getInvitation("org", "pending"); + + assertEquals("pending", result.getId()); + assertEquals("org", result.getOrganizationId()); + assertEquals("user@example.com", result.getEmail()); + assertEquals(InvitationStatus.pending, result.getStatus()); + assertFalse(result.isSetMetadata()); + } + + @Test + void shouldRejectInvitationThatBelongsToAnotherOrganization() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("alien", "org")).thenReturn(Optional.empty()); + + assertThrows(InvitationNotFound.class, () -> service.getInvitation("org", "alien")); + } + + /** Просрочка считается по expires_at, не дожидаясь фоновой задачи. */ + @Test + void shouldReportPendingInvitationAsExpiredBeforeSchedulerSweepsIt() throws Exception { + InvitationEntity stale = invitation("stale", LocalDateTime.now().minusMinutes(1)); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("stale", "org")).thenReturn(Optional.of(stale)); + + assertEquals(InvitationStatus.expired, service.getInvitation("org", "stale").getStatus()); + } + + @Test + void shouldRejectRevokingInvitationThatIsNotPending() { + InvitationEntity revoked = invitation("revoked", LocalDateTime.now().plusDays(1), "Revoked"); + InvitationEntity accepted = invitation("accepted", LocalDateTime.now().plusDays(1), "Accepted"); + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(invitationRepository.findByIdAndOrganizationId("revoked", "org")).thenReturn(Optional.of(revoked)); + when(invitationRepository.findByIdAndOrganizationId("accepted", "org")).thenReturn(Optional.of(accepted)); + + assertThrows(InvalidInvitationState.class, + () -> service.revokeInvitation("org", "revoked", new RevokeInvitationRequest("obsolete"))); + assertThrows(InvalidInvitationState.class, + () -> service.revokeInvitation("org", "accepted", new RevokeInvitationRequest("obsolete"))); + verify(invitationRepository, never()).save(any()); + } + + @Test + void shouldRejectInvitationWithMetadataThatRestWouldFailToRead() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidRequest.class, () -> service.createInvitation( + "org", + new CreateInvitationRequest("user@example.com", new ArrayList<>()).setMetadata("[1,2]"))); + verifyNoInteractions(mailMessageSender, invitationRepository); + } + + @Test + void shouldKeepScopeResourceIdOnAssignedRole() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var assigned = service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Shop").setResourceId("shop-1"))); + + assertEquals("manager", assigned.getRoleId()); + assertEquals("Shop", assigned.getScope().getScopeId()); + assertEquals("shop-1", assigned.getScope().getResourceId()); + } + + @Test + void shouldRejectBlankIdentifiersInRoleAssignment() { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) + .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + + assertThrows(InvalidRequest.class, + () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest(" "))); + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope(" ")))); + verifyNoInteractions(memberRoleRepository); + } + + @Test + void shouldRejectMemberWithBlankUserId() { + assertThrows(InvalidRequest.class, + () -> service.addMember("org", new AddMemberRequest(" ", "user@example.com"))); + verifyNoInteractions(organizationRepository, memberRepository); + } + + @Test + void shouldListOrganizationRolesSortedByRoleId() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setRoles(new HashSet<>(Set.of( + role("manager", "Manager", "Shop"), + role("accountant", "Accountant")))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + + var roles = service.listOrganizationRoles("org"); + + assertEquals(List.of("accountant", "manager"), roles.stream().map(OrganizationRole::getId).toList()); + assertTrue(roles.get(0).getScopeIds().isEmpty()); + assertEquals(List.of("Shop"), roles.get(1).getScopeIds()); + } + + @Test + void shouldRejectActivatingAlreadyActiveOrganization() { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + + assertThrows(InvalidOrganizationState.class, () -> service.activateOrganization("org")); + verify(organizationRepository, never()).save(any()); + } + + /** + * Размер страницы доходит до запроса с ограничением сверху и с лишней строкой, + * по которой считается continuation token. + */ + @SuppressWarnings("unchecked") + @Test + void shouldCapRequestedPageSizeAndAlwaysAskForOneExtraRow() throws Exception { + final ArgumentCaptor users = ArgumentCaptor.forClass(Pageable.class); + final ArgumentCaptor members = ArgumentCaptor.forClass(Pageable.class); + final ArgumentCaptor organizations = ArgumentCaptor.forClass(Pageable.class); + when(memberRepository.getUserPage(isNull(), isNull(), any(Pageable.class))).thenReturn(List.of()); + when(organizationRepository.existsById("org")).thenReturn(true); + when(memberRepository.getOrgMemberIds(eq("org"), isNull(), any(Pageable.class))).thenReturn(List.of()); + when(organizationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of())); + + service.listUsers(new ListUsersRequest().setLimit(MAX_PAGE_LIMIT + 100)); + service.listMembers("org", new ListMembersRequest()); + service.listOrganizations(new ListOrganizationsRequest().setLimit(-5)); + + verify(memberRepository).getUserPage(isNull(), isNull(), users.capture()); + verify(memberRepository).getOrgMemberIds(eq("org"), isNull(), members.capture()); + verify(organizationRepository).findAll(any(Specification.class), organizations.capture()); + assertEquals(MAX_PAGE_LIMIT + 1, users.getValue().getPageSize()); + assertEquals(DEFAULT_PAGE_LIMIT + 1, members.getValue().getPageSize()); + assertEquals(DEFAULT_PAGE_LIMIT + 1, organizations.getValue().getPageSize()); + assertEquals(Sort.by(Sort.Direction.DESC, "id"), organizations.getValue().getSort()); + } + private UserDto userRow(String userId, String email) { return new UserDto() { @Override @@ -702,6 +969,10 @@ private OrganizationEntity organization(String id, OrganizationStatus status) { } private InvitationEntity invitation(String id, LocalDateTime expiresAt) { + return invitation(id, expiresAt, "Pending"); + } + + private InvitationEntity invitation(String id, LocalDateTime expiresAt, String status) { return InvitationEntity.builder() .id(id) .organizationId("org") @@ -709,7 +980,7 @@ private InvitationEntity invitation(String id, LocalDateTime expiresAt) { .expiresAt(expiresAt) .inviteeContactEmail("user@example.com") .inviteeRoles(new HashSet<>()) - .status("Pending") + .status(status) .build(); } } From 22faf666139097733a9eb248d76d82fd70450960 Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Thu, 17 Sep 2026 16:27:41 +0400 Subject: [PATCH 18/19] Drop organization role catalog from Admin Management --- pom.xml | 2 +- .../converter/AdminManagementConverter.java | 12 -- .../repository/ScopeRepository.java | 9 - .../service/AdminCommonService.java | 33 +--- .../service/AdminInvitationService.java | 2 +- .../service/AdminManagementService.java | 19 -- .../service/AdminMemberService.java | 2 +- .../service/AdminOrganizationService.java | 71 +------- .../repository/AbstractRepositoryTest.java | 11 +- .../service/AdminManagementServiceTest.java | 169 ++++-------------- 10 files changed, 56 insertions(+), 274 deletions(-) delete mode 100644 src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java diff --git a/pom.xml b/pom.xml index a22f3380..a136f37e 100644 --- a/pom.xml +++ b/pom.xml @@ -60,7 +60,7 @@ dev.vality org-management-proto - 1.22-4a6c2e7 + 1.23-e544bc9 dev.vality diff --git a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java index 8b9f2ffa..1de20a9d 100644 --- a/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java +++ b/src/main/java/dev/vality/orgmanager/converter/AdminManagementConverter.java @@ -5,7 +5,6 @@ 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; @@ -14,7 +13,6 @@ 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 dev.vality.orgmanager.service.dto.UserDto; @@ -29,7 +27,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; @Component public class AdminManagementConverter { @@ -158,15 +155,6 @@ public InvitationStatus effectiveInvitationStatus(InvitationEntity entity) { 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) { diff --git a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java b/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java deleted file mode 100644 index 2031b652..00000000 --- a/src/main/java/dev/vality/orgmanager/repository/ScopeRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.vality.orgmanager.repository; - -import dev.vality.orgmanager.entity.ScopeEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ScopeRepository extends JpaRepository { -} diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java index f8edae19..c3fec6c9 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -5,20 +5,14 @@ import dev.vality.orgmanagement.RoleScope; import dev.vality.orgmanager.entity.MemberRoleEntity; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.repository.OrganizationRepository; -import dev.vality.orgmanager.repository.OrganizationRoleRepository; 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.Objects; -import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -28,7 +22,6 @@ public class AdminCommonService { public static final int MAX_PAGE_LIMIT = 1000; private final OrganizationRepository organizationRepository; - private final OrganizationRoleRepository organizationRoleRepository; private final JsonCodec jsonCodec; public OrganizationEntity findOrganization(String organizationId) throws OrganizationNotFound { @@ -55,30 +48,14 @@ public String requireText(String value, String field) throws InvalidRequest { } /** - * Проверяет, что назначение роли опирается на каталог ролей организации: сама роль в нём есть, - * а указанный scope_id входит в её список допустимых областей действия. + * Область действия без resource_id не ограничивает роль, поэтому такое назначение отклоняется. */ - public void validateRoleAssignment(String organizationId, String roleId, RoleScope scope) - throws InvalidRequest { + public void validateRoleAssignment(String roleId, RoleScope scope) throws InvalidRequest { requireText(roleId, "Role id"); - OrganizationRoleEntity role = organizationRoleRepository - .findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(() -> new InvalidRequest( - "Role " + roleId + " is not present in the organization role catalog")); - if (scope == null) { - return; + if (scope != null) { + requireText(scope.getScopeId(), "Scope id"); + requireText(scope.getResourceId(), "Scope resource id"); } - String scopeId = requireText(scope.getScopeId(), "Scope id"); - if (!allowedScopeIds(role).contains(scopeId)) { - throw new InvalidRequest("Scope " + scopeId + " is not allowed for role " + roleId); - } - } - - public Set allowedScopeIds(OrganizationRoleEntity role) { - return collectionOrEmpty(role.getPossibleScopes()).stream() - .map(ScopeEntity::getId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); } public MemberRoleEntity toMemberRoleEntity(String organizationId, String roleId, RoleScope scope) { diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 6cd39365..5d30cd31 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -67,7 +67,7 @@ public Invitation create(String organizationId, CreateInvitationRequest request) String metadata = commonService.toStoredMetadata(request.getMetadata()); Set roles = new LinkedHashSet<>(); for (RoleAssignment role : collectionOrEmpty(request.getRoles())) { - commonService.validateRoleAssignment(organizationId, role.getRoleId(), role.getScope()); + commonService.validateRoleAssignment(role.getRoleId(), role.getScope()); roles.add(commonService.toMemberRoleEntity(organizationId, role.getRoleId(), role.getScope())); } LocalDateTime now = LocalDateTime.now(); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java index 62ad8526..427557d8 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminManagementService.java @@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class AdminManagementService implements AdminManagementSrv.Iface { @@ -64,23 +62,6 @@ public User getUser(String userId) throws UnknownUser { return adminUserService.get(userId); } - @Override - public OrganizationRole getOrganizationRole(String organizationId, String roleId) - throws OrganizationNotFound, RoleNotFound { - return adminOrganizationService.getRole(organizationId, roleId); - } - - @Override - public List listOrganizationRoles(String organizationId) throws OrganizationNotFound { - return adminOrganizationService.listRoles(organizationId); - } - - @Override - public OrganizationRole setOrganizationRole(String organizationId, SetOrganizationRoleRequest request) - throws OrganizationNotFound, InvalidRequest { - return adminOrganizationService.setRole(organizationId, request); - } - @Override public Member getMember(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { return adminMemberService.get(organizationId, userId); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index f8a50fca..6ad56fcc 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -133,7 +133,7 @@ public MemberRole assignRole( organizationId, userId, request.getRoleId()); OrganizationEntity organization = commonService.findOrganization(organizationId); MemberEntity member = findMember(organization, userId); - commonService.validateRoleAssignment(organizationId, request.getRoleId(), request.getScope()); + commonService.validateRoleAssignment(request.getRoleId(), request.getScope()); MemberRoleEntity role = commonService.toMemberRoleEntity( organizationId, request.getRoleId(), request.getScope()); role = memberRoleRepository.save(role); diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index fd108b0c..daf38fb9 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -8,18 +8,11 @@ import dev.vality.orgmanagement.ModifyOrganizationRequest; import dev.vality.orgmanagement.Organization; import dev.vality.orgmanagement.OrganizationNotFound; -import dev.vality.orgmanagement.OrganizationRole; import dev.vality.orgmanagement.PartyAlreadyBound; -import dev.vality.orgmanagement.RoleNotFound; -import dev.vality.orgmanagement.SetOrganizationRoleRequest; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.OrganizationEntity; -import dev.vality.orgmanager.entity.OrganizationRoleEntity; -import dev.vality.orgmanager.entity.ScopeEntity; import dev.vality.orgmanager.entity.StoredOrganizationStatus; import dev.vality.orgmanager.repository.OrganizationRepository; -import dev.vality.orgmanager.repository.OrganizationRoleRepository; -import dev.vality.orgmanager.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.AdminPage; import jakarta.persistence.criteria.Predicate; import lombok.RequiredArgsConstructor; @@ -34,19 +27,15 @@ import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import java.util.UUID; -import static dev.vality.orgmanager.service.AdminCommonService.collectionOrEmpty; import static dev.vality.orgmanager.service.AdminCommonService.pageLimit; import static java.util.Objects.requireNonNullElseGet; /** - * Организации и их роли в административном контракте. + * Организации в административном контракте. */ @Slf4j @Service @@ -54,8 +43,6 @@ public class AdminOrganizationService { private final OrganizationRepository organizationRepository; - private final OrganizationRoleRepository organizationRoleRepository; - private final ScopeRepository scopeRepository; private final AdminManagementConverter converter; private final AdminCommonService commonService; @@ -143,62 +130,6 @@ public Organization activate(String organizationId) throws OrganizationNotFound, return changeStatus(organizationId, StoredOrganizationStatus.DEACTIVATED, StoredOrganizationStatus.ACTIVE); } - @Transactional(readOnly = true) - public OrganizationRole getRole(String organizationId, String roleId) - throws OrganizationNotFound, RoleNotFound { - log.info("Get organization role: organizationId={}, roleId={}", organizationId, roleId); - commonService.findOrganization(organizationId); - OrganizationRoleEntity role = organizationRoleRepository.findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseThrow(RoleNotFound::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(); - } - - /** - * Создаёт либо обновляет роль в каталоге ролей организации. Каталог задаёт, какие роли - * и области действия допустимы в AssignMemberRole и CreateInvitation - */ - @Transactional - public OrganizationRole setRole(String organizationId, SetOrganizationRoleRequest request) - throws OrganizationNotFound, InvalidRequest { - log.info("Set organization role: organizationId={}, request={}", organizationId, request); - if (request == null) { - throw new InvalidRequest("Request must not be null"); - } - commonService.findOrganization(organizationId); - String roleId = commonService.requireText(request.getRoleId(), "Role id"); - String name = commonService.requireText(request.getName(), "Role name"); - OrganizationRoleEntity role = organizationRoleRepository - .findByOrganizationIdAndRoleId(organizationId, roleId) - .orElseGet(() -> OrganizationRoleEntity.builder() - .id(UUID.randomUUID().toString()) - .organizationId(organizationId) - .roleId(roleId) - .build()); - role.setName(name); - role.setPossibleScopes(resolveScopes(request.getScopeIds())); - return converter.toOrganizationRole(organizationRoleRepository.save(role)); - } - - private Set resolveScopes(List scopeIds) throws InvalidRequest { - Set scopes = new LinkedHashSet<>(); - for (String scopeId : scopeIds == null ? List.of() : scopeIds) { - commonService.requireText(scopeId, "Scope id"); - scopes.add(scopeRepository.findById(scopeId) - .orElseGet(() -> scopeRepository.save(ScopeEntity.builder().id(scopeId).build()))); - } - return scopes; - } - private Specification specification(ListOrganizationsRequest request) { return (root, query, builder) -> { List predicates = new ArrayList<>(); diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index e522acd3..9af25186 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -48,10 +48,17 @@ public void setUp() throws Exception { invitationRepository.deleteAll(); memberContextRepository.deleteAll(); organizationRoleRepository.deleteAll(); - organizationRepository.deleteAll(); + // Связи с каскадом PERSIST иначе отменяют удаление при flush + var organizations = organizationRepository.findAll(); + organizations.forEach(it -> it.getMembers().clear()); + organizationRepository.saveAll(organizations); var members = memberRepository.findAll(); - members.forEach(it -> it.getRoles().clear()); + members.forEach(it -> { + it.getRoles().clear(); + it.getOrganizations().clear(); + }); memberRepository.saveAll(members); + organizationRepository.deleteAll(); memberRepository.deleteAll(); memberRoleRepository.deleteAll(); } diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index 86d30d00..96b46a31 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -7,14 +7,10 @@ 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.ScopeEntity; 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.repository.ScopeRepository; import dev.vality.orgmanager.service.dto.MemberWithRoleDto; import dev.vality.orgmanager.service.dto.UserDto; import dev.vality.orgmanager.util.JsonCodec; @@ -63,10 +59,6 @@ class AdminManagementServiceTest { @Mock private MemberRoleRepository memberRoleRepository; @Mock - private OrganizationRoleRepository organizationRoleRepository; - @Mock - private ScopeRepository scopeRepository; - @Mock private InvitationRepository invitationRepository; @Mock private InviteTokenProperties inviteTokenProperties; @@ -80,13 +72,10 @@ void setUp() { AdminManagementConverter converter = new AdminManagementConverter(); AdminCommonService commonService = new AdminCommonService( organizationRepository, - organizationRoleRepository, new JsonCodec(JsonMapper.builder().build())); service = new AdminManagementService( new AdminOrganizationService( organizationRepository, - organizationRoleRepository, - scopeRepository, converter, commonService), new AdminUserService(memberRepository, converter), @@ -229,55 +218,6 @@ void shouldValidateOrganizationStatusTransitions() throws Exception { assertEquals(OrganizationStatus.active, service.activateOrganization("org").getStatus()); } - @Test - void shouldRejectUnknownRoleWithRoleNotFound() { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - - assertThrows(RoleNotFound.class, () -> service.getOrganizationRole("org", "manager")); - } - - @Test - void shouldCreateRoleInCatalogAndThenUpdateIt() throws Exception { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - when(organizationRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(scopeRepository.findById("Shop")).thenReturn(Optional.empty()); - when(scopeRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - - var created = service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", "Manager", List.of("Shop"))); - - assertEquals("manager", created.getId()); - assertEquals("Manager", created.getName()); - assertEquals(List.of("Shop"), created.getScopeIds()); - - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); - - var updated = service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", "Shop manager", new ArrayList<>())); - - assertEquals("Shop manager", updated.getName()); - assertTrue(updated.getScopeIds().isEmpty()); - } - - @Test - void shouldRejectRoleWithBlankName() { - when(organizationRepository.findById("org")) - .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - - assertThrows(InvalidRequest.class, () -> service.setOrganizationRole( - "org", - new SetOrganizationRoleRequest("manager", " ", List.of()))); - } - @Test void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); @@ -285,8 +225,6 @@ void shouldAddMemberAndManageRoleWithoutLastRoleRestriction() throws Exception { 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)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager"))); var member = service.addMember("org", new AddMemberRequest("user", "user@example.com")); var role = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager")); @@ -325,37 +263,24 @@ void shouldRejectMemberWithoutEmail() { } @Test - void shouldRejectRoleThatIsMissingFromCatalog() { - OrganizationEntity organization = organization("org", OrganizationStatus.active); - organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() - .id("user") - .roles(new HashSet<>()) - .build()))); - when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); - - assertThrows(InvalidRequest.class, - () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest("manager"))); - verifyNoInteractions(memberRoleRepository); - } - - @Test - void shouldRejectScopeThatRoleDoesNotAllow() { + void shouldAssignArbitraryRoleAndScope() throws Exception { OrganizationEntity organization = organization("org", OrganizationStatus.active); organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() .id("user") .roles(new HashSet<>()) .build()))); when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + var assigned = service.assignMemberRole( "org", "user", - new AssignMemberRoleRequest("manager").setScope(new RoleScope("Wallet")))); - verifyNoInteractions(memberRoleRepository); + new AssignMemberRoleRequest("CustomRole").setScope(new RoleScope("Wallet").setResourceId("w-1"))); + + assertEquals("CustomRole", assigned.getRoleId()); + assertEquals("Wallet", assigned.getScope().getScopeId()); + assertEquals("w-1", assigned.getScope().getResourceId()); } @Test @@ -458,17 +383,32 @@ void shouldCreateInvitationAndSendMail() throws Exception { } @Test - void shouldRejectInvitationWithRoleOutsideCatalog() { + void shouldCreateInvitationWithScopedRole() throws Exception { + when(organizationRepository.findById("org")) + .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); + when(inviteTokenProperties.getLifeTimeInDays()).thenReturn(7L); + when(invitationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + CreateInvitationRequest request = new CreateInvitationRequest( + "user@example.com", + List.of(new RoleAssignment("Manager").setScope(new RoleScope("Shop").setResourceId("shop-1")))); + + var result = service.createInvitation("org", request); + + assertEquals(1, result.getRoles().size()); + assertEquals("Manager", result.getRoles().get(0).getRoleId()); + assertEquals("shop-1", result.getRoles().get(0).getScope().getResourceId()); + } + + @Test + void shouldRejectInvitationWithBlankRole() { when(organizationRepository.findById("org")) .thenReturn(Optional.of(organization("org", OrganizationStatus.active))); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.empty()); CreateInvitationRequest request = new CreateInvitationRequest( "user@example.com", - List.of(new RoleAssignment("manager"))); + List.of(new RoleAssignment(" "))); assertThrows(InvalidRequest.class, () -> service.createInvitation("org", request)); - verifyNoInteractions(mailMessageSender); + verifyNoInteractions(mailMessageSender, invitationRepository); } @Test @@ -625,7 +565,6 @@ void shouldRejectBlankUserIdWithoutQuery() { verifyNoInteractions(memberRepository); } - @Test void shouldRejectUnknownOrganizationForEveryOrganizationScopedMethod() { when(organizationRepository.findById("gone")).thenReturn(Optional.empty()); @@ -637,11 +576,6 @@ void shouldRejectUnknownOrganizationForEveryOrganizationScopedMethod() { new ModifyOrganizationRequest().setName("renamed"))); assertThrows(OrganizationNotFound.class, () -> service.deactivateOrganization("gone")); assertThrows(OrganizationNotFound.class, () -> service.activateOrganization("gone")); - assertThrows(OrganizationNotFound.class, () -> service.getOrganizationRole("gone", "manager")); - assertThrows(OrganizationNotFound.class, () -> service.listOrganizationRoles("gone")); - assertThrows(OrganizationNotFound.class, () -> service.setOrganizationRole( - "gone", - new SetOrganizationRoleRequest("manager", "Manager", new ArrayList<>()))); assertThrows(OrganizationNotFound.class, () -> service.getMember("gone", "user")); assertThrows(OrganizationNotFound.class, () -> service.listMembers("gone", new ListMembersRequest())); assertThrows(OrganizationNotFound.class, () -> service.addMember( @@ -680,7 +614,7 @@ void shouldRejectUnknownMemberForEveryMemberScopedMethod() { new AssignMemberRoleRequest("manager"))); assertThrows(MemberNotFound.class, () -> service.removeMemberRole("org", "ghost", "role")); - verifyNoInteractions(memberRepository, memberRoleRepository, organizationRoleRepository); + verifyNoInteractions(memberRepository, memberRoleRepository); } /** @@ -747,7 +681,9 @@ void shouldRejectInvitationThatBelongsToAnotherOrganization() { assertThrows(InvitationNotFound.class, () -> service.getInvitation("org", "alien")); } - /** Просрочка считается по expires_at, не дожидаясь фоновой задачи. */ + /** + * Просрочка считается по expires_at, не дожидаясь фоновой задачи. + */ @Test void shouldReportPendingInvitationAsExpiredBeforeSchedulerSweepsIt() throws Exception { InvitationEntity stale = invitation("stale", LocalDateTime.now().minusMinutes(1)); @@ -793,8 +729,6 @@ void shouldKeepScopeResourceIdOnAssignedRole() throws Exception { .roles(new HashSet<>()) .build()))); when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); @@ -816,15 +750,17 @@ void shouldRejectBlankIdentifiersInRoleAssignment() { .roles(new HashSet<>()) .build()))); when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - when(organizationRoleRepository.findByOrganizationIdAndRoleId("org", "manager")) - .thenReturn(Optional.of(role("manager", "Manager", "Shop"))); assertThrows(InvalidRequest.class, () -> service.assignMemberRole("org", "user", new AssignMemberRoleRequest(" "))); assertThrows(InvalidRequest.class, () -> service.assignMemberRole( "org", "user", - new AssignMemberRoleRequest("manager").setScope(new RoleScope(" ")))); + new AssignMemberRoleRequest("manager").setScope(new RoleScope(" ").setResourceId("shop-1")))); + assertThrows(InvalidRequest.class, () -> service.assignMemberRole( + "org", + "user", + new AssignMemberRoleRequest("manager").setScope(new RoleScope("Shop")))); verifyNoInteractions(memberRoleRepository); } @@ -835,21 +771,6 @@ void shouldRejectMemberWithBlankUserId() { verifyNoInteractions(organizationRepository, memberRepository); } - @Test - void shouldListOrganizationRolesSortedByRoleId() throws Exception { - OrganizationEntity organization = organization("org", OrganizationStatus.active); - organization.setRoles(new HashSet<>(Set.of( - role("manager", "Manager", "Shop"), - role("accountant", "Accountant")))); - when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); - - var roles = service.listOrganizationRoles("org"); - - assertEquals(List.of("accountant", "manager"), roles.stream().map(OrganizationRole::getId).toList()); - assertTrue(roles.get(0).getScopeIds().isEmpty()); - assertEquals(List.of("Shop"), roles.get(1).getScopeIds()); - } - @Test void shouldRejectActivatingAlreadyActiveOrganization() { when(organizationRepository.findById("org")) @@ -941,20 +862,6 @@ public String getResourceId() { }; } - private OrganizationRoleEntity role(String roleId, String name, String... scopeIds) { - Set scopes = new HashSet<>(); - for (String scopeId : scopeIds) { - scopes.add(ScopeEntity.builder().id(scopeId).build()); - } - return OrganizationRoleEntity.builder() - .id("catalog-" + roleId) - .organizationId("org") - .roleId(roleId) - .name(name) - .possibleScopes(scopes) - .build(); - } - private OrganizationEntity organization(String id, OrganizationStatus status) { return OrganizationEntity.builder() .id(id) From d6e847920c7ae5e7d5508e1b3cad76adcc65f5ca Mon Sep 17 00:00:00 2001 From: jvmdev4 Date: Fri, 18 Sep 2026 00:14:09 +0400 Subject: [PATCH 19/19] Fix concurrency issues in Admin Management --- .../repository/MemberRepository.java | 9 ++ .../repository/OrganizationRepository.java | 46 +++++--- .../service/AdminCommonService.java | 5 + .../service/AdminInvitationService.java | 4 +- .../service/AdminMemberService.java | 102 ++++++++++++------ .../service/AdminOrganizationService.java | 16 ++- .../repository/AbstractRepositoryTest.java | 2 +- .../service/AdminManagementServiceTest.java | 29 ++++- 8 files changed, 147 insertions(+), 66 deletions(-) diff --git a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java index 1a9651a5..c2f009bc 100644 --- a/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/MemberRepository.java @@ -5,6 +5,7 @@ import dev.vality.orgmanager.service.dto.UserDto; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.stereotype.Repository; @@ -15,6 +16,14 @@ public interface MemberRepository extends JpaRepository { + /** + * Пользователь общий для всех организаций, поэтому создаётся без конфликта по ключу. + */ + @Modifying(flushAutomatically = true) + @NativeQuery("INSERT INTO org_manager.member (id, email) VALUES (?1, ?2) " + + " ON CONFLICT (id) DO UPDATE SET email = excluded.email") + void upsert(String userId, String email); + @NativeQuery("SELECT m.id, " + " m.email, " + " mr.id as memberRoleId, " + diff --git a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java index bfbe3acf..8782a55d 100644 --- a/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java +++ b/src/main/java/dev/vality/orgmanager/repository/OrganizationRepository.java @@ -15,26 +15,26 @@ public interface OrganizationRepository extends JpaRepository, JpaSpecificationExecutor { @NativeQuery(" SELECT * FROM org_manager.organization AS o " + - " WHERE o.id IN " + - " ( " + - " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + - " WHERE mo.member_id = ?1 " + - " UNION " + - " SELECT id FROM org_manager.organization WHERE owner = ?1 " + - " ) " + - " ORDER BY o.id DESC") + " WHERE o.id IN " + + " ( " + + " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + + " WHERE mo.member_id = ?1 " + + " UNION " + + " SELECT id FROM org_manager.organization WHERE owner = ?1 " + + " ) " + + " ORDER BY o.id DESC") List findAllByMember(String userId); @NativeQuery(" SELECT * FROM org_manager.organization AS o " + - " WHERE o.id IN " + - " ( " + - " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + - " WHERE mo.member_id = ?1 " + - " UNION " + - " SELECT id FROM org_manager.organization WHERE owner = ?1 " + - " ) " + - " AND o.id < ?2 " + - " ORDER BY o.id DESC") + " WHERE o.id IN " + + " ( " + + " SELECT mo.organization_id FROM org_manager.member_to_organization AS mo " + + " WHERE mo.member_id = ?1 " + + " UNION " + + " SELECT id FROM org_manager.organization WHERE owner = ?1 " + + " ) " + + " AND o.id < ?2 " + + " ORDER BY o.id DESC") List findAllByMember(String userId, String continuationId); Set findAllByOwner(String owner); @@ -43,4 +43,16 @@ public interface OrganizationRepository Optional findByParty(String party); + /** + * Блокирует party до конца транзакции: строки для блокировки ещё нет. + */ + @NativeQuery("SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext(?1))) AS locked") + Integer lockByParty(String party); + + /** + * Блокирует строку организации до конца транзакции. + */ + @NativeQuery("SELECT id FROM org_manager.organization WHERE id = ?1 FOR UPDATE") + Optional lockById(String organizationId); + } diff --git a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java index c3fec6c9..d3df538d 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminCommonService.java @@ -29,6 +29,11 @@ public OrganizationEntity findOrganization(String organizationId) throws Organiz .orElseThrow(OrganizationNotFound::new); } + public OrganizationEntity lockOrganization(String organizationId) throws OrganizationNotFound { + organizationRepository.lockById(organizationId).orElseThrow(OrganizationNotFound::new); + return findOrganization(organizationId); + } + public String toStoredMetadata(String metadata) throws InvalidRequest { if (metadata == null) { return null; diff --git a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java index 5d30cd31..ee9b43f1 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminInvitationService.java @@ -62,7 +62,7 @@ public class AdminInvitationService { public Invitation create(String organizationId, CreateInvitationRequest request) throws OrganizationNotFound, InvalidRequest { log.info("Create invitation: organizationId={}, email={}", organizationId, request.getEmail()); - commonService.findOrganization(organizationId); + commonService.lockOrganization(organizationId); String email = commonService.requireText(request.getEmail(), "Email"); String metadata = commonService.toStoredMetadata(request.getMetadata()); Set roles = new LinkedHashSet<>(); @@ -137,7 +137,7 @@ public ListInvitationsResult list(String organizationId, ListInvitationsRequest public void revoke(String organizationId, String invitationId, RevokeInvitationRequest request) throws OrganizationNotFound, InvitationNotFound, InvalidInvitationState { log.info("Revoke invitation: organizationId={}, invitationId={}", organizationId, invitationId); - commonService.findOrganization(organizationId); + commonService.lockOrganization(organizationId); InvitationEntity invitation = findInvitation(organizationId, invitationId); InvitationStatus status = converter.effectiveInvitationStatus(invitation); if (status != InvitationStatus.pending) { diff --git a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java index 6ad56fcc..4c9eb302 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminMemberService.java @@ -10,6 +10,7 @@ import dev.vality.orgmanagement.MemberRole; import dev.vality.orgmanagement.MemberRoleNotFound; import dev.vality.orgmanagement.OrganizationNotFound; +import dev.vality.orgmanagement.RoleScope; import dev.vality.orgmanager.converter.AdminManagementConverter; import dev.vality.orgmanager.entity.MemberEntity; import dev.vality.orgmanager.entity.MemberRoleEntity; @@ -24,7 +25,6 @@ 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; @@ -82,18 +82,14 @@ public Member add(String organizationId, AddMemberRequest request) log.info("Add member: organizationId={}, userId={}", organizationId, request.getUserId()); String userId = commonService.requireText(request.getUserId(), "User id"); String email = commonService.requireText(request.getEmail(), "Email"); - OrganizationEntity organization = commonService.findOrganization(organizationId); - MemberEntity member = memberRepository.findById(userId) - .orElseGet(() -> MemberEntity.builder() - .id(userId) - .roles(new HashSet<>()) - .organizations(new HashSet<>()) - .build()); + OrganizationEntity organization = commonService.lockOrganization(organizationId); + memberRepository.upsert(userId, email); + MemberEntity member = memberRepository.findById(userId).orElseThrow(); member.setEmail(email); - member = memberRepository.save(member); - Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); - members.add(member); - organization.setMembers(members); + Set members = organizationMembers(organization); + if (!containsMember(members, userId)) { + members.add(member); + } organizationRepository.save(organization); return converter.toMember(member, organizationId); } @@ -101,26 +97,18 @@ public Member add(String organizationId, AddMemberRequest request) @Transactional public void remove(String organizationId, String userId) throws OrganizationNotFound, MemberNotFound { log.info("Remove member: organizationId={}, userId={}", organizationId, userId); - OrganizationEntity organization = commonService.findOrganization(organizationId); + OrganizationEntity organization = commonService.lockOrganization(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); + List removedRoles = memberRoles(member).stream() + .filter(role -> organizationId.equals(role.getOrganizationId()) && role.isActive()) + .toList(); + removedRoles.forEach(role -> role.setActive(false)); + removedRoles.forEach(memberRoles(member)::remove); memberRoleRepository.saveAll(removedRoles); memberRepository.save(member); - Set members = new HashSet<>(collectionOrEmpty(organization.getMembers())); - members.remove(member); - organization.setMembers(members); + organizationMembers(organization).removeIf(candidate -> candidate.getId().equals(userId)); organizationRepository.save(organization); } @@ -131,15 +119,17 @@ public MemberRole assignRole( AssignMemberRoleRequest request) throws OrganizationNotFound, MemberNotFound, InvalidRequest { log.info("Assign member role: organizationId={}, userId={}, roleId={}", organizationId, userId, request.getRoleId()); - OrganizationEntity organization = commonService.findOrganization(organizationId); + OrganizationEntity organization = commonService.lockOrganization(organizationId); MemberEntity member = findMember(organization, userId); commonService.validateRoleAssignment(request.getRoleId(), request.getScope()); + MemberRoleEntity assigned = findAssignedRole(member, organizationId, request); + if (assigned != null) { + return converter.toMemberRole(assigned); + } 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); + memberRoles(member).add(role); memberRepository.save(member); return converter.toMemberRole(role); } @@ -149,7 +139,7 @@ public void removeRole(String organizationId, String userId, String memberRoleId throws OrganizationNotFound, MemberNotFound, MemberRoleNotFound { log.info("Remove member role: organizationId={}, userId={}, memberRoleId={}", organizationId, userId, memberRoleId); - OrganizationEntity organization = commonService.findOrganization(organizationId); + OrganizationEntity organization = commonService.lockOrganization(organizationId); MemberEntity member = findMember(organization, userId); MemberRoleEntity role = collectionOrEmpty(member.getRoles()).stream() .filter(candidate -> candidate.getId().equals(memberRoleId)) @@ -158,13 +148,55 @@ public void removeRole(String organizationId, String userId, String memberRoleId .findFirst() .orElseThrow(MemberRoleNotFound::new); role.setActive(false); - Set roles = new HashSet<>(collectionOrEmpty(member.getRoles())); - roles.remove(role); - member.setRoles(roles); + memberRoles(member).remove(role); memberRoleRepository.save(role); memberRepository.save(member); } + /** + * Уже назначенная роль с той же областью действия, если она есть. + */ + private MemberRoleEntity findAssignedRole( + MemberEntity member, + String organizationId, + AssignMemberRoleRequest request) { + RoleScope scope = request.getScope(); + return memberRoles(member).stream() + .filter(MemberRoleEntity::isActive) + .filter(role -> organizationId.equals(role.getOrganizationId())) + .filter(role -> role.getRoleId().equals(request.getRoleId())) + .filter(role -> scope == null + ? role.getScopeId() == null + : scope.getScopeId().equals(role.getScopeId()) + && scope.getResourceId().equals(role.getResourceId())) + .findFirst() + .orElse(null); + } + + /** + * Коллекция меняется на месте: подстановка новой пересоздаёт связующую таблицу целиком. + */ + private Set memberRoles(MemberEntity member) { + if (member.getRoles() == null) { + member.setRoles(new HashSet<>()); + } + return member.getRoles(); + } + + /** + * Поиск по идентификатору: equals участника учитывает email. + */ + private boolean containsMember(Set members, String userId) { + return members.stream().anyMatch(member -> member.getId().equals(userId)); + } + + private Set organizationMembers(OrganizationEntity organization) { + if (organization.getMembers() == null) { + organization.setMembers(new HashSet<>()); + } + return organization.getMembers(); + } + private MemberEntity findMember(OrganizationEntity organization, String userId) throws MemberNotFound { return collectionOrEmpty(organization.getMembers()).stream() .filter(member -> member.getId().equals(userId)) diff --git a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java index daf38fb9..fa5f3de1 100644 --- a/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java +++ b/src/main/java/dev/vality/orgmanager/service/AdminOrganizationService.java @@ -17,7 +17,6 @@ import jakarta.persistence.criteria.Predicate; 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.data.domain.Sort; @@ -49,11 +48,12 @@ public class AdminOrganizationService { @Transactional public Organization create(CreateOrganizationRequest request) throws PartyAlreadyBound, InvalidRequest { log.info("Create organization: partyId={}, ownerId={}", request.getPartyId(), request.getOwnerId()); - commonService.requireText(request.getPartyId(), "Party id"); + String partyId = commonService.requireText(request.getPartyId(), "Party id"); commonService.requireText(request.getOwnerId(), "Owner id"); String name = commonService.requireText(request.getName(), "Organization name"); String metadata = commonService.toStoredMetadata(request.getMetadata()); - if (organizationRepository.existsByParty(request.getPartyId())) { + organizationRepository.lockByParty(partyId); + if (organizationRepository.existsByParty(partyId)) { throw new PartyAlreadyBound(); } OrganizationEntity entity = OrganizationEntity.builder() @@ -67,11 +67,7 @@ public Organization create(CreateOrganizationRequest request) throws PartyAlread .members(new HashSet<>()) .roles(new HashSet<>()) .build(); - try { - return converter.toOrganization(organizationRepository.saveAndFlush(entity)); - } catch (DataIntegrityViolationException exception) { - throw new PartyAlreadyBound(); - } + return converter.toOrganization(organizationRepository.saveAndFlush(entity)); } @Transactional(readOnly = true) @@ -108,7 +104,7 @@ public Organization modify(String organizationId, ModifyOrganizationRequest requ throws OrganizationNotFound, InvalidRequest { log.info("Modify organization: organizationId={}, request={}", organizationId, request); ModifyOrganizationRequest safeRequest = request == null ? new ModifyOrganizationRequest() : request; - OrganizationEntity organization = commonService.findOrganization(organizationId); + OrganizationEntity organization = commonService.lockOrganization(organizationId); if (safeRequest.isSetName()) { organization.setName(commonService.requireText(safeRequest.getName(), "Organization name")); } @@ -150,7 +146,7 @@ private Organization changeStatus( String organizationId, StoredOrganizationStatus expected, StoredOrganizationStatus target) throws OrganizationNotFound, InvalidOrganizationState { - OrganizationEntity organization = commonService.findOrganization(organizationId); + OrganizationEntity organization = commonService.lockOrganization(organizationId); String current = organization.getStatus(); if (!expected.matches(current)) { throw new InvalidOrganizationState( diff --git a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java index 9af25186..b8178133 100644 --- a/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java +++ b/src/test/java/dev/vality/orgmanager/repository/AbstractRepositoryTest.java @@ -48,7 +48,7 @@ public void setUp() throws Exception { invitationRepository.deleteAll(); memberContextRepository.deleteAll(); organizationRoleRepository.deleteAll(); - // Связи с каскадом PERSIST иначе отменяют удаление при flush + // Без разрыва связей каскад PERSIST отменяет удаление при flush var organizations = organizationRepository.findAll(); organizations.forEach(it -> it.getMembers().clear()); organizationRepository.saveAll(organizations); diff --git a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java index 96b46a31..139d9b47 100644 --- a/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java +++ b/src/test/java/dev/vality/orgmanager/service/AdminManagementServiceTest.java @@ -44,7 +44,9 @@ import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -69,6 +71,14 @@ class AdminManagementServiceTest { @BeforeEach void setUp() { + lenient().when(organizationRepository.lockById(any())) + .thenAnswer(invocation -> Optional.of(invocation.getArgument(0))); + lenient().when(memberRepository.findById(any())).thenAnswer(invocation -> Optional.of( + MemberEntity.builder() + .id(invocation.getArgument(0)) + .roles(new HashSet<>()) + .organizations(new HashSet<>()) + .build())); AdminManagementConverter converter = new AdminManagementConverter(); AdminCommonService commonService = new AdminCommonService( organizationRepository, @@ -247,7 +257,6 @@ void shouldUpdateEmailWhenMemberIsAddedTwice() throws Exception { when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); when(organizationRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(memberRepository.findById("user")).thenReturn(Optional.of(existing)); - when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); var member = service.addMember("org", new AddMemberRequest("user", "new@example.com")); @@ -283,6 +292,24 @@ void shouldAssignArbitraryRoleAndScope() throws Exception { assertEquals("w-1", assigned.getScope().getResourceId()); } + @Test + void shouldReturnExistingAssignmentWhenSameRoleIsAssignedTwice() throws Exception { + OrganizationEntity organization = organization("org", OrganizationStatus.active); + organization.setMembers(new HashSet<>(Set.of(MemberEntity.builder() + .id("user") + .roles(new HashSet<>()) + .build()))); + when(organizationRepository.findById("org")).thenReturn(Optional.of(organization)); + when(memberRoleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(memberRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + var first = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("Manager")); + var second = service.assignMemberRole("org", "user", new AssignMemberRoleRequest("Manager")); + + assertEquals(first.getId(), second.getId()); + verify(memberRoleRepository, times(1)).save(any()); + } + @Test void shouldRejectRemovingRoleFromAnotherOrganization() throws Exception { MemberRoleEntity role = MemberRoleEntity.builder()