From 03c8d0b23b5652a05f305dcc2138fbc903fe87e7 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 14 Sep 2026 11:11:36 +0000 Subject: [PATCH] feat(saas-api): Graphql schema for FE integration for tenant management --- .../OrganizationFilterOptions.java | 2 + .../OrganizationQueryService.java | 1 + .../service/OrganizationQueryServiceTest.java | 21 ++++++++ .../datafetcher/AssignmentDataFetcher.java | 3 +- .../api/datafetcher/DeviceDataFetcher.java | 3 +- .../api/datafetcher/TimeEntryDataFetcher.java | 3 +- .../dataloader/OrganizationDataLoader.java | 9 +++- .../filter/OrganizationQueryFilter.java | 2 + .../CustomOrganizationRepositoryImpl.java | 7 +++ .../CustomOrganizationRepositoryImplIT.java | 53 +++++++++++++++++++ 10 files changed, 99 insertions(+), 5 deletions(-) diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/organization/OrganizationFilterOptions.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/organization/OrganizationFilterOptions.java index 4aeda3db7b..1a7455c5d5 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/organization/OrganizationFilterOptions.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/organization/OrganizationFilterOptions.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.Instant; +import java.util.Set; /** * Internal DTO for organization filter options. @@ -22,4 +23,5 @@ public class OrganizationFilterOptions { private String status; private Instant lastActivityFrom; private Instant lastActivityTo; + private Set excludeOrganizationIds; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/organization/OrganizationQueryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/organization/OrganizationQueryService.java index 6efeeeac32..61a082f77a 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/organization/OrganizationQueryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/organization/OrganizationQueryService.java @@ -123,6 +123,7 @@ private OrganizationQueryFilter buildQueryFilter(OrganizationFilterOptions filte .status(filterOptions.getStatus()) .lastActivityFrom(filterOptions.getLastActivityFrom()) .lastActivityTo(filterOptions.getLastActivityTo()) + .excludeOrganizationIds(filterOptions.getExcludeOrganizationIds()) .build(); } diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/OrganizationQueryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/OrganizationQueryServiceTest.java index b2f5174ac3..3184f7d958 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/OrganizationQueryServiceTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/OrganizationQueryServiceTest.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -125,6 +126,26 @@ void lastActivityRangeIsForwardedToRepository() { assertThat(forwarded.getLastActivityTo()).isEqualTo(to); } + @Test + @DisplayName("excludeOrganizationIds on the FilterOptions must reach the repository — the directory connection picker relies on it to hide customers that already have a connection") + void excludeOrganizationIdsIsForwardedToRepository() { + when(repository.countOrganizations(any())).thenReturn(0L); + when(repository.findOrganizationsWithCursor(any(), any(), anyInt(), any(), any())) + .thenReturn(List.of()); + + Set excluded = Set.of("org-a", "org-b"); + OrganizationFilterOptions options = OrganizationFilterOptions.builder() + .excludeOrganizationIds(excluded) + .build(); + + service.queryOrganizations(options, page(20), null, lastActivity(SortDirection.DESC)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganizationQueryFilter.class); + verify(repository).buildOrganizationQuery(captor.capture(), any()); + OrganizationQueryFilter forwarded = captor.getValue(); + assertThat(forwarded.getExcludeOrganizationIds()).containsExactlyInAnyOrderElementsOf(excluded); + } + @Test @DisplayName("legacy _id sort keeps a plain ObjectId cursor") void plainCursorForLegacyIdSort() { diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/AssignmentDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/AssignmentDataFetcher.java index 151dcddec6..d8bc5356d1 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/AssignmentDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/AssignmentDataFetcher.java @@ -1,6 +1,7 @@ package com.openframe.api.datafetcher; import com.netflix.graphql.dgs.*; +import com.openframe.api.dataloader.OrganizationDataLoader; import com.openframe.api.dto.CountedGenericConnection; import com.openframe.api.dto.CountedGenericQueryResult; import com.openframe.api.dto.GenericEdge; @@ -111,7 +112,7 @@ public CompletableFuture resolveTarget(DgsDataFetchingEnvironment dfe) { ItemAssignment assignment = dfe.getSource(); String targetId = assignment.getTargetId(); return switch (assignment.getTargetType()) { - case ORGANIZATION -> dfe.getDataLoader("organizationDataLoader").load(targetId); + case ORGANIZATION -> dfe.getDataLoader(OrganizationDataLoader.NAME).load(targetId); case DEVICE -> dfe.getDataLoader("machineDataLoader").load(targetId); case TICKET -> dfe.getDataLoader("ticketDataLoader").load(targetId); case KNOWLEDGE_ARTICLE -> dfe.getDataLoader("knowledgeBaseItemDataLoader").load(targetId); diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java index 2b5f08a930..8c7056fae5 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java @@ -6,6 +6,7 @@ import com.netflix.graphql.dgs.DgsMutation; import com.netflix.graphql.dgs.DgsQuery; import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dataloader.OrganizationDataLoader; import com.openframe.api.dto.CountedGenericConnection; import com.openframe.api.dto.CountedGenericQueryResult; import com.openframe.api.dto.GenericEdge; @@ -181,7 +182,7 @@ public CompletableFuture> installedAgents(DgsDataFetchingEn @DgsData(parentType = "Machine") public CompletableFuture organization(DgsDataFetchingEnvironment dfe) { - DataLoader dataLoader = dfe.getDataLoader("organizationDataLoader"); + DataLoader dataLoader = dfe.getDataLoader(OrganizationDataLoader.NAME); Machine machine = dfe.getSource(); String organizationId = machine.getOrganizationId(); diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/TimeEntryDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/TimeEntryDataFetcher.java index 575c057f35..51e35e6bba 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/TimeEntryDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/TimeEntryDataFetcher.java @@ -6,6 +6,7 @@ import com.netflix.graphql.dgs.DgsMutation; import com.netflix.graphql.dgs.DgsQuery; import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dataloader.OrganizationDataLoader; import com.openframe.api.dto.CountedGenericConnection; import com.openframe.api.dto.CountedGenericQueryResult; import com.openframe.api.dto.GenericEdge; @@ -228,7 +229,7 @@ public CompletableFuture timeEntryOrganization(DgsDataFetchingEnvi if (entry.getOrganizationId() == null) { return CompletableFuture.completedFuture(null); } - DataLoader loader = dfe.getDataLoader("organizationDataLoader"); + DataLoader loader = dfe.getDataLoader(OrganizationDataLoader.NAME); return loader.load(entry.getOrganizationId()); } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/dataloader/OrganizationDataLoader.java b/openframe-api-service-core/src/main/java/com/openframe/api/dataloader/OrganizationDataLoader.java index a6c96090c0..3a894a6759 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/dataloader/OrganizationDataLoader.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/dataloader/OrganizationDataLoader.java @@ -6,7 +6,10 @@ import lombok.RequiredArgsConstructor; import org.dataloader.BatchLoader; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.stream.Collectors; @@ -15,10 +18,12 @@ * DataLoader for batch loading Organization objects by organizationId. * This prevents N+1 query problems when loading organizations for multiple machines. */ -@DgsDataLoader(name = "organizationDataLoader") +@DgsDataLoader(name = OrganizationDataLoader.NAME) @RequiredArgsConstructor public class OrganizationDataLoader implements BatchLoader { + public static final String NAME = "organizationDataLoader"; + private final OrganizationRepository organizationRepository; @Override diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/organization/filter/OrganizationQueryFilter.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/organization/filter/OrganizationQueryFilter.java index 7ed5f8207f..fef62b25e6 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/organization/filter/OrganizationQueryFilter.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/organization/filter/OrganizationQueryFilter.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import java.time.Instant; +import java.util.Set; /** * Filter criteria for organization queries. @@ -23,4 +24,5 @@ public class OrganizationQueryFilter { private String status; private Instant lastActivityFrom; private Instant lastActivityTo; + private Set excludeOrganizationIds; } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImpl.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImpl.java index d32f76823a..6fa356be78 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImpl.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImpl.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; /** @@ -30,6 +31,7 @@ public class CustomOrganizationRepositoryImpl implements CustomOrganizationRepos private static final String ID_FIELD = "_id"; private static final String UPDATED_AT_FIELD = "updatedAt"; private static final String CURSOR_SEPARATOR = "_"; + private static final String ORGANIZATION_ID_FIELD = "organizationId"; private static final List SORTABLE_FIELDS = List.of( "_id", @@ -100,6 +102,11 @@ public Query buildOrganizationQuery(OrganizationQueryFilter filter, String searc if (filter.getLastActivityTo() != null) { criteriaList.add(Criteria.where(UPDATED_AT_FIELD).lte(filter.getLastActivityTo())); } + + Set excludedOrganizationIds = filter.getExcludeOrganizationIds(); + if (excludedOrganizationIds != null && !excludedOrganizationIds.isEmpty()) { + criteriaList.add(Criteria.where(ORGANIZATION_ID_FIELD).nin(excludedOrganizationIds)); + } } else { // No filter provided — default to ACTIVE status criteriaList.add(new Criteria().orOperator( diff --git a/openframe-data-mongo-sync/src/test/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImplIT.java b/openframe-data-mongo-sync/src/test/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImplIT.java index 26cd0abd8b..42bc3f626e 100644 --- a/openframe-data-mongo-sync/src/test/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImplIT.java +++ b/openframe-data-mongo-sync/src/test/java/com/openframe/data/repository/organization/CustomOrganizationRepositoryImplIT.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -192,6 +193,58 @@ void countReturnsFilteredTotal() { assertThat(repository.countOrganizations(query)).isEqualTo(7L); } + @Test + @DisplayName("excludeOrganizationIds keeps those organizations out of the page") + void excludeOrganizationIdsFiltersThePage() { + save("a", 100); + save("b", 200); + save("c", 300); + + OrganizationQueryFilter filter = OrganizationQueryFilter.builder() + .status(OrganizationStatus.ACTIVE.name()) + .excludeOrganizationIds(Set.of("b")) + .build(); + + Query query = repository.buildOrganizationQuery(filter, null); + List result = repository.findOrganizationsWithCursor(query, null, 50, SORT_UPDATED_AT, DESC); + + assertThat(result).extracting(Organization::getName).containsExactly("c", "a"); + } + + @Test + @DisplayName("countOrganizations honours excludeOrganizationIds, so filteredCount matches the page") + void countHonoursExcludeOrganizationIds() { + for (int i = 1; i <= 5; i++) { + save("o" + i, i * 100L); + } + + OrganizationQueryFilter filter = OrganizationQueryFilter.builder() + .status(OrganizationStatus.ACTIVE.name()) + .excludeOrganizationIds(Set.of("o2", "o4")) + .build(); + + Query query = repository.buildOrganizationQuery(filter, null); + + assertThat(repository.countOrganizations(query)).isEqualTo(3L); + } + + @Test + @DisplayName("an empty excludeOrganizationIds set excludes nobody") + void emptyExcludeOrganizationIdsKeepsEveryone() { + save("a", 100); + save("b", 200); + + OrganizationQueryFilter filter = OrganizationQueryFilter.builder() + .status(OrganizationStatus.ACTIVE.name()) + .excludeOrganizationIds(Set.of()) + .build(); + + Query query = repository.buildOrganizationQuery(filter, null); + List result = repository.findOrganizationsWithCursor(query, null, 50, SORT_UPDATED_AT, DESC); + + assertThat(result).extracting(Organization::getName).containsExactly("b", "a"); + } + @Test @DisplayName("legacy _id cursor still paginates without overlap") void legacyIdCursorPaginates() {