From c9b74d6de0eb961a266b0be806ab70df1d037561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 30 Jun 2026 14:40:50 +0200 Subject: [PATCH] Filter trust center subprocessors server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subprocessor filtering for the compliance portal happens in the backend rather than the client. Add a SubprocessorFilter (query, category, country) to the trust API's subprocessors connection, thread it through the resolver and service, and extend the coredata ThirdParty filter with category equality and country array membership. The connection stores the filter so totalCount reflects the filtered set. Add e2e coverage for the new filtering. On the frontend, convert the page to a refetchable fragment whose filter arguments are driven by URL-persisted, debounced toolbar state (category and region selects plus a search field), populate the dropdowns from an unfiltered facet selection, and offer to clear filters from the empty state. Signed-off-by: Émile Ré --- .../pages/subprocessors/SubprocessorsPage.tsx | 53 ++++- .../subprocessors/SubprocessorsPageLoader.tsx | 11 +- .../_components/SubprocessorsEmpty.tsx | 23 +- .../_components/SubprocessorsToolbar.tsx | 110 +++++++++ .../subprocessors/_lib/toQueryVariables.ts | 32 +++ .../_lib/useSubprocessorFilters.ts | 97 ++++++++ .../pages/subprocessors/_locales/en-US.json | 10 +- .../pages/subprocessors/_locales/fr-FR.json | 10 +- e2e/trust/subprocessors_filter_test.go | 222 ++++++++++++++++++ pkg/cookiebanner/tracker_mapping_worker.go | 2 +- pkg/coredata/third_party_filter.go | 32 ++- pkg/probo/generated_document_service.go | 2 +- pkg/probo/third_party_service.go | 2 +- .../api/console/v1/organization_resolvers.go | 2 +- pkg/server/api/mcp/v1/schema.resolvers.go | 2 +- .../api/trust/v1/graphql/trust_center.graphql | 7 + .../api/trust/v1/trust_center_resolvers.go | 22 +- pkg/server/api/trust/v1/types/third_party.go | 3 + pkg/trust/compliance_page_service.go | 5 +- pkg/trust/third_party_service.go | 7 +- 20 files changed, 623 insertions(+), 31 deletions(-) create mode 100644 apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx create mode 100644 apps/compliance-portal/src/pages/subprocessors/_lib/toQueryVariables.ts create mode 100644 apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts create mode 100644 e2e/trust/subprocessors_filter_test.go diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index faeae9940..6bfaec46f 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -12,22 +12,41 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { PreloadedQuery } from "react-relay"; -import { graphql, usePreloadedQuery } from "react-relay"; +import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"; import { PageHeader } from "#/components/PageHeader/PageHeader"; import { SubprocessorCategorySection } from "./_components/SubprocessorCategorySection"; import type { SubprocessorNode } from "./_components/SubprocessorCategorySection"; import { SubprocessorsEmpty } from "./_components/SubprocessorsEmpty"; +import { SubprocessorsToolbar } from "./_components/SubprocessorsToolbar"; import { groupByCategory } from "./_lib/groupByCategory"; +import { toQueryVariables } from "./_lib/toQueryVariables"; +import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters"; import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql"; +import type { SubprocessorsPageRefetchQuery } from "./__generated__/SubprocessorsPageRefetchQuery.graphql"; +import type { SubprocessorsPage_query$key } from "./__generated__/SubprocessorsPage_query.graphql"; export const subprocessorsPageQuery = graphql` - query SubprocessorsPageQuery { + query SubprocessorsPageQuery($query: String, $category: SubprocessorCategory, $country: CountryCode) { + ...SubprocessorsPage_query @arguments(query: $query, category: $category, country: $country) + ...SubprocessorsToolbar_query + } +`; + +const subprocessorsPageFragment = graphql` + fragment SubprocessorsPage_query on Query + @refetchable(queryName: "SubprocessorsPageRefetchQuery") + @argumentDefinitions( + query: { type: "String" } + category: { type: "SubprocessorCategory" } + country: { type: "CountryCode" } + ) { currentTrustCenter @required(action: THROW) { - subprocessors(first: 250) { + subprocessors(first: 250, filter: { query: $query, category: $category, country: $country }) { totalCount edges { node { @@ -47,15 +66,35 @@ interface SubprocessorsPageProps { export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) { const { t } = useTranslation("subprocessors"); - const data = usePreloadedQuery(subprocessorsPageQuery, queryRef); - const { subprocessors } = data.currentTrustCenter; + const root = usePreloadedQuery(subprocessorsPageQuery, queryRef); + const [data, refetch] = useRefetchableFragment( + subprocessorsPageFragment, + root, + ); + const filters = useSubprocessorFilters(); + const { query, category, country } = filters; + + // The initial query already loaded with the URL's filter values; only refetch + // on subsequent filter changes. + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + refetch(toQueryVariables({ query, category, country }), { fetchPolicy: "store-or-network" }); + }, [refetch, query, category, country]); + + const { subprocessors } = data.currentTrustCenter; const nodes: SubprocessorNode[] = subprocessors.edges.map(edge => edge.node); - const groups = groupByCategory(nodes, category => t(`categories.${category}.label`)); + const groups = groupByCategory(nodes, value => t(`categories.${value}.label`)); return ( <> - + + +
{groups.length === 0 diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx index 388031ce1..fcb9947c3 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx @@ -12,18 +12,25 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useQueryLoader } from "react-relay"; import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage"; import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton"; +import { toQueryVariables } from "./_lib/toQueryVariables"; +import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters"; import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql"; export default function SubprocessorsPageLoader() { + const filters = useSubprocessorFilters(); const [queryRef, loadQuery] = useQueryLoader(subprocessorsPageQuery); + // Seed the first fetch with the URL's filter values; later changes are handled + // by the page's refetch. + const initialVariables = useRef(toQueryVariables(filters)); + useEffect(() => { - loadQuery({}); + loadQuery(initialVariables.current); }, [loadQuery]); if (!queryRef) { diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsEmpty.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsEmpty.tsx index 720fd4cfc..555bc4dfb 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsEmpty.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsEmpty.tsx @@ -12,25 +12,40 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { ArrowCounterClockwiseIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; import { Text } from "@probo/ui/src/v2/typography/Text"; import { useTranslation } from "react-i18next"; -// Empty state shown when the trust center lists no subprocessors. +import { useSubprocessorFilters } from "../_lib/useSubprocessorFilters"; + +// Empty state for the subprocessor list. When filters are active it offers to +// clear them; otherwise it states the trust center lists no subprocessors. export function SubprocessorsEmpty() { const { t } = useTranslation("subprocessors"); + const { hasActiveFilters, clear } = useSubprocessorFilters(); return (
- {t("empty.title")} + {hasActiveFilters ? t("empty.filteredTitle") : t("empty.title")} - {t("empty.description")} + {hasActiveFilters ? t("empty.filteredDescription") : t("empty.description")}
+ {hasActiveFilters && ( + + )}
); } diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx new file mode 100644 index 000000000..d5e50fd47 --- /dev/null +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { Select } from "@probo/ui/src/v2/Select/Select"; +import { SelectItem } from "@probo/ui/src/v2/Select/SelectItem"; +import { SelectPopup } from "@probo/ui/src/v2/Select/SelectPopup"; +import { SelectTrigger } from "@probo/ui/src/v2/Select/SelectTrigger"; +import { TextField } from "@probo/ui/src/v2/form/TextField"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { graphql, useFragment } from "react-relay"; + +import { useCountryLabel } from "../_lib/useCountryLabel"; +import { useSubprocessorFilters } from "../_lib/useSubprocessorFilters"; + +import type { SubprocessorsToolbar_query$key } from "./__generated__/SubprocessorsToolbar_query.graphql"; + +// Unfiltered facet data: the distinct categories and countries actually present, +// used to populate the filter dropdowns (aliased so it does not collide with the +// filtered list selection on the same trust center). +const subprocessorsToolbarFragment = graphql` + fragment SubprocessorsToolbar_query on Query { + currentTrustCenter @required(action: THROW) { + allSubprocessors: subprocessors(first: 250) { + edges { + node { + id + category + countries + } + } + } + } + } +`; + +interface SubprocessorsToolbarProps { + queryKey: SubprocessorsToolbar_query$key; +} + +// Category / region / search controls. Writes filter state to the URL via the +// filter hook; the page reacts to those changes and refetches server-side. +export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) { + const { t } = useTranslation("subprocessors"); + const data = useFragment(subprocessorsToolbarFragment, queryKey); + const countryLabel = useCountryLabel(); + const { queryInput, category, country, setQueryInput, setCategory, setCountry } = useSubprocessorFilters(); + + const nodes = data.currentTrustCenter.allSubprocessors.edges.map(edge => edge.node); + + const categoryOptions = useMemo(() => { + const present = [...new Set(nodes.map(node => node.category))]; + return present.sort((a, b) => t(`categories.${a}.label`).localeCompare(t(`categories.${b}.label`))); + }, [nodes, t]); + + const countryOptions = useMemo(() => { + const present = [...new Set(nodes.flatMap(node => node.countries))]; + return present.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b))); + }, [nodes, countryLabel]); + + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/subprocessors/_lib/toQueryVariables.ts b/apps/compliance-portal/src/pages/subprocessors/_lib/toQueryVariables.ts new file mode 100644 index 000000000..1b7eacbdb --- /dev/null +++ b/apps/compliance-portal/src/pages/subprocessors/_lib/toQueryVariables.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { SubprocessorsPageQuery$variables } from "../__generated__/SubprocessorsPageQuery.graphql"; + +interface FilterValues { + query: string; + category: string; + country: string; +} + +// Maps the string-based URL filter state to the typed GraphQL query variables, +// treating empty strings as "no filter". The category/country strings originate +// from the enum-constrained Select options, so the cast is safe. +export function toQueryVariables(filters: FilterValues): SubprocessorsPageQuery$variables { + return { + query: filters.query || null, + category: (filters.category || null) as SubprocessorsPageQuery$variables["category"], + country: (filters.country || null) as SubprocessorsPageQuery$variables["country"], + }; +} diff --git a/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts new file mode 100644 index 000000000..91a7a7edf --- /dev/null +++ b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; + +const SEARCH_DEBOUNCE_MS = 300; + +export interface SubprocessorFilters { + // Debounced search term written to the URL (drives the query variables). + query: string; + category: string; + country: string; + // Immediate search input value (updates on every keystroke). + queryInput: string; + hasActiveFilters: boolean; + setQueryInput: (value: string) => void; + setCategory: (value: string) => void; + setCountry: (value: string) => void; + clear: () => void; +} + +// Subprocessor filter state, persisted in the URL so it is shareable and +// survives reloads. The search term is debounced before it is committed to the +// URL (and therefore before it triggers a refetch). +export function useSubprocessorFilters(): SubprocessorFilters { + const [searchParams, setSearchParams] = useSearchParams(); + + const category = searchParams.get("category") ?? ""; + const country = searchParams.get("country") ?? ""; + const query = searchParams.get("q") ?? ""; + + const [queryInput, setQueryInput] = useState(query); + + useEffect(() => { + if (queryInput === query) { + return; + } + + const handle = setTimeout(() => { + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + if (queryInput) { + next.set("q", queryInput); + } else { + next.delete("q"); + } + return next; + }, { replace: true }); + }, SEARCH_DEBOUNCE_MS); + + return () => clearTimeout(handle); + }, [queryInput, query, setSearchParams]); + + const setParam = useCallback((key: string, value: string) => { + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + if (value) { + next.set(key, value); + } else { + next.delete(key); + } + return next; + }, { replace: true }); + }, [setSearchParams]); + + const setCategory = useCallback((value: string) => setParam("category", value), [setParam]); + const setCountry = useCallback((value: string) => setParam("country", value), [setParam]); + + const clear = useCallback(() => { + setQueryInput(""); + setSearchParams({}, { replace: true }); + }, [setSearchParams]); + + return { + query, + category, + country, + queryInput, + hasActiveFilters: query !== "" || category !== "" || country !== "", + setQueryInput, + setCategory, + setCountry, + clear, + }; +} diff --git a/apps/compliance-portal/src/pages/subprocessors/_locales/en-US.json b/apps/compliance-portal/src/pages/subprocessors/_locales/en-US.json index ec6d7a03e..b74a1c8df 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/subprocessors/_locales/en-US.json @@ -1,8 +1,16 @@ { "title": "Subprocessors", + "filters": { + "allCategories": "All categories", + "allRegions": "All regions", + "searchPlaceholder": "Search..." + }, "empty": { "title": "No subprocessors listed.", - "description": "This trust center has not published any subprocessors yet." + "description": "This trust center has not published any subprocessors yet.", + "filteredTitle": "No subprocessors match your filters.", + "filteredDescription": "Adjust your search or filters to see available subprocessors.", + "clearFilters": "Clear filters" }, "regions": { "global": "Global", diff --git a/apps/compliance-portal/src/pages/subprocessors/_locales/fr-FR.json b/apps/compliance-portal/src/pages/subprocessors/_locales/fr-FR.json index f9390a342..30473620e 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/subprocessors/_locales/fr-FR.json @@ -1,8 +1,16 @@ { "title": "Sous-traitants", + "filters": { + "allCategories": "Toutes les catégories", + "allRegions": "Toutes les régions", + "searchPlaceholder": "Rechercher..." + }, "empty": { "title": "Aucun sous-traitant répertorié.", - "description": "Ce centre de confiance n'a pas encore publié de sous-traitants." + "description": "Ce centre de confiance n'a pas encore publié de sous-traitants.", + "filteredTitle": "Aucun sous-traitant ne correspond à vos filtres.", + "filteredDescription": "Ajustez votre recherche ou vos filtres pour voir les sous-traitants disponibles.", + "clearFilters": "Réinitialiser les filtres" }, "regions": { "global": "International", diff --git a/e2e/trust/subprocessors_filter_test.go b/e2e/trust/subprocessors_filter_test.go new file mode 100644 index 000000000..60e649f8d --- /dev/null +++ b/e2e/trust/subprocessors_filter_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package trust_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +func TestTrustCenter_SubprocessorsFilter(t *testing.T) { + t.Parallel() + + owner := testutil.NewClient(t, testutil.RoleOwner) + trustCenterID := activateTrustCenter(t, owner) + + awsName := factory.SafeName("AWS") + awsID := factory.NewThirdParty(owner).WithName(awsName).WithCategory("CLOUD_PROVIDER").Create() + publishSubprocessor(t, owner, awsID, []string{"US"}) + + stripeName := factory.SafeName("Stripe") + stripeID := factory.NewThirdParty(owner).WithName(stripeName).WithCategory("FINANCE").Create() + publishSubprocessor(t, owner, stripeID, []string{"US", "IE"}) + + slackName := factory.SafeName("Slack") + slackID := factory.NewThirdParty(owner).WithName(slackName).WithCategory("COLLABORATION").Create() + publishSubprocessor(t, owner, slackID, []string{"US"}) + + t.Run("no filter returns every published subprocessor", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, nil) + assert.Equal(t, 3, result.CurrentTrustCenter.Subprocessors.TotalCount) + assert.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 3) + }) + + t.Run("category filter narrows to one category", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, map[string]any{ + "category": "CLOUD_PROVIDER", + }) + require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount) + require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1) + assert.Equal(t, awsName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name) + }) + + t.Run("country filter matches array membership", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, map[string]any{ + "country": "IE", + }) + require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount) + require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1) + assert.Equal(t, stripeName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name) + }) + + t.Run("query filter matches name substring", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, map[string]any{ + "query": slackName, + }) + require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount) + require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1) + assert.Equal(t, slackName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name) + }) + + t.Run("combined filters intersect", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, map[string]any{ + "category": "FINANCE", + "country": "US", + }) + require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount) + require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1) + assert.Equal(t, stripeName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name) + }) + + t.Run("non-matching filter returns empty set", func(t *testing.T) { + t.Parallel() + + result := querySubprocessors(t, owner, trustCenterID, map[string]any{ + "category": "SECURITY", + }) + assert.Equal(t, 0, result.CurrentTrustCenter.Subprocessors.TotalCount) + assert.Empty(t, result.CurrentTrustCenter.Subprocessors.Edges) + }) +} + +type subprocessorsResult struct { + CurrentTrustCenter struct { + Subprocessors struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Countries []string `json:"countries"` + } `json:"node"` + } `json:"edges"` + } `json:"subprocessors"` + } `json:"currentTrustCenter"` +} + +func querySubprocessors( + t *testing.T, + owner *testutil.Client, + trustCenterID string, + filter map[string]any, +) subprocessorsResult { + t.Helper() + + const query = ` + query($filter: SubprocessorFilter) { + currentTrustCenter { + subprocessors(first: 50, filter: $filter) { + totalCount + edges { + node { + id + name + category + countries + } + } + } + } + } + ` + + var result subprocessorsResult + err := owner.ExecuteTrust(trustCenterID, query, map[string]any{"filter": filter}, &result) + require.NoError(t, err) + + return result +} + +func activateTrustCenter(t *testing.T, owner *testutil.Client) string { + t.Helper() + + const trustCenterQuery = ` + query($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + trustCenter { id } + } + } + } + ` + + var lookup struct { + Node struct { + TrustCenter struct { + ID string `json:"id"` + } `json:"trustCenter"` + } `json:"node"` + } + + err := owner.Execute(trustCenterQuery, map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + }, &lookup) + require.NoError(t, err) + require.NotEmpty(t, lookup.Node.TrustCenter.ID) + + const activateMutation = ` + mutation($input: UpdateTrustCenterInput!) { + updateTrustCenter(input: $input) { + trustCenter { id active } + } + } + ` + + err = owner.Execute(activateMutation, map[string]any{ + "input": map[string]any{ + "trustCenterId": lookup.Node.TrustCenter.ID, + "active": true, + }, + }, nil) + require.NoError(t, err) + + return lookup.Node.TrustCenter.ID +} + +func publishSubprocessor(t *testing.T, owner *testutil.Client, thirdPartyID string, countries []string) { + t.Helper() + + const mutation = ` + mutation($input: UpdateThirdPartyInput!) { + updateThirdParty(input: $input) { + thirdParty { id showOnTrustCenter countries } + } + } + ` + + err := owner.Execute(mutation, map[string]any{ + "input": map[string]any{ + "id": thirdPartyID, + "showOnTrustCenter": true, + "countries": countries, + }, + }, nil) + require.NoError(t, err) +} diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index f4169da94..f889c406f 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -1433,7 +1433,7 @@ func (h *trackerMappingHandler) prepareOrgThirdParty( }, func(ctx context.Context, cursor *page.Cursor[coredata.ThirdPartyOrderField]) ([]*coredata.ThirdParty, error) { var batch coredata.ThirdParties - if err := batch.LoadByOrganizationID(ctx, conn, scope, tp.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil)); err != nil { + if err := batch.LoadByOrganizationID(ctx, conn, scope, tp.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil, nil, nil)); err != nil { return nil, fmt.Errorf("cannot load org third parties: %w", err) } diff --git a/pkg/coredata/third_party_filter.go b/pkg/coredata/third_party_filter.go index d13e04f8f..dd03a6da5 100644 --- a/pkg/coredata/third_party_filter.go +++ b/pkg/coredata/third_party_filter.go @@ -23,14 +23,24 @@ type ( showOnTrustCenter *bool level *int query *string + category *ThirdPartyCategory + country *CountryCode } ) -func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter { +func NewThirdPartyFilter( + showOnTrustCenter *bool, + level *int, + query *string, + category *ThirdPartyCategory, + country *CountryCode, +) *ThirdPartyFilter { return &ThirdPartyFilter{ showOnTrustCenter: showOnTrustCenter, level: level, query: query, + category: category, + country: country, } } @@ -39,6 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs { "show_on_trust_center": nil, "filter_query": nil, "level": nil, + "filter_category": nil, + "filter_country": nil, } if f.showOnTrustCenter != nil { @@ -53,6 +65,14 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs { args["level"] = *f.level } + if f.category != nil { + args["filter_category"] = string(*f.category) + } + + if f.country != nil { + args["filter_country"] = string(*f.country) + } + return args } @@ -74,5 +94,15 @@ func (f *ThirdPartyFilter) SQLFragment() string { name ILIKE '%' || @filter_query || '%' ELSE TRUE END + AND CASE + WHEN @filter_category::text IS NOT NULL THEN + category = @filter_category::third_party_category + ELSE TRUE + END + AND CASE + WHEN @filter_country::text IS NOT NULL THEN + @filter_country::country_code = ANY(countries) + ELSE TRUE + END )` } diff --git a/pkg/probo/generated_document_service.go b/pkg/probo/generated_document_service.go index 08af2681b..3ae2f5876 100644 --- a/pkg/probo/generated_document_service.go +++ b/pkg/probo/generated_document_service.go @@ -2508,7 +2508,7 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData( }, func(ctx context.Context, cursor *page.Cursor[coredata.ThirdPartyOrderField]) ([]*coredata.ThirdParty, error) { var batch coredata.ThirdParties - if err := batch.LoadByOrganizationID(ctx, conn, scope, organization.ID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil)); err != nil { + if err := batch.LoadByOrganizationID(ctx, conn, scope, organization.ID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil, nil, nil)); err != nil { return nil, fmt.Errorf("cannot load thirdParties: %w", err) } diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index dd7583e94..5f23529e2 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -174,7 +174,7 @@ func (s ThirdPartyService) CountForOrganizationID( var count int if filter == nil { - filter = coredata.NewThirdPartyFilter(nil, nil, nil) + filter = coredata.NewThirdPartyFilter(nil, nil, nil, nil, nil) } err := s.svc.pg.WithConn( diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 745875e05..9da86d57c 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -1284,7 +1284,7 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga query = filter.Query } - thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query) + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query, nil, nil) page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter) if err != nil { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 13bda79c6..d0c166382 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -75,7 +75,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy) - thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil) + thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil, nil, nil) page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter) if err != nil { diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index fb91ee142..749b5e2de 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -30,6 +30,7 @@ type TrustCenter implements Node { after: CursorKey last: Int before: CursorKey + filter: SubprocessorFilter ): SubprocessorConnection! @goField(forceResolver: true) references( @@ -245,6 +246,12 @@ type Subprocessor implements Node @nda { countries: [CountryCode!]! } +input SubprocessorFilter { + query: String + category: SubprocessorCategory + country: CountryCode +} + type SubprocessorConnection @goModel( model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.SubprocessorConnection" diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/trust/v1/trust_center_resolvers.go index 57dd3e19b..48a10f1ba 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/trust/v1/trust_center_resolvers.go @@ -670,7 +670,7 @@ func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *ty switch obj.Resolver.(type) { case *trustCenterResolver: - count, err := trustService.ThirdParties.CountForTrustCenterId(ctx, scope, obj.ParentID) + count, err := trustService.ThirdParties.CountForTrustCenterId(ctx, scope, obj.ParentID, obj.Filter) if err != nil { r.logger.ErrorCtx(ctx, "cannot count subprocessors", log.Error(err)) return 0, gqlutils.Internal(ctx) @@ -798,7 +798,7 @@ func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter } // Subprocessors is the resolver for the subprocessors field. -func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SubprocessorConnection, error) { +func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.SubprocessorFilter) (*types.SubprocessorConnection, error) { compliancePage := compliancepage.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) trustService := r.trust @@ -808,13 +808,27 @@ func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.Trus } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - thirdPartyPage, err := trustService.ThirdParties.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor) + var ( + query *string + category *coredata.ThirdPartyCategory + country *coredata.CountryCode + ) + if filter != nil { + query = filter.Query + category = filter.Category + country = filter.Country + } + + showOnTrustCenter := true + thirdPartyFilter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, query, category, country) + + thirdPartyPage, err := trustService.ThirdParties.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, thirdPartyFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list subprocessors", log.Error(err)) return nil, gqlutils.Internal(ctx) } - return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID), nil + return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID, thirdPartyFilter), nil } // References is the resolver for the references field. diff --git a/pkg/server/api/trust/v1/types/third_party.go b/pkg/server/api/trust/v1/types/third_party.go index a0343c3eb..3a0cd5f32 100644 --- a/pkg/server/api/trust/v1/types/third_party.go +++ b/pkg/server/api/trust/v1/types/third_party.go @@ -28,6 +28,7 @@ type ( Resolver any ParentID gid.GID + Filter *coredata.ThirdPartyFilter } ) @@ -35,6 +36,7 @@ func NewSubprocessorConnection( p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], parentType any, parentID gid.GID, + filter *coredata.ThirdPartyFilter, ) *SubprocessorConnection { edges := make([]*SubprocessorEdge, len(p.Data)) for i, thirdParty := range p.Data { @@ -47,6 +49,7 @@ func NewSubprocessorConnection( Resolver: parentType, ParentID: parentID, + Filter: filter, } } diff --git a/pkg/trust/compliance_page_service.go b/pkg/trust/compliance_page_service.go index 585e85a58..cc478566c 100644 --- a/pkg/trust/compliance_page_service.go +++ b/pkg/trust/compliance_page_service.go @@ -553,7 +553,10 @@ func (s *Service) fetchThirdParties(ctx context.Context, scope coredata.Scoper, }, ) - result, err := s.ThirdParties.ListForOrganizationId(ctx, scope, orgID, cursor) + showOnTrustCenter := true + filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil, nil, nil) + + result, err := s.ThirdParties.ListForOrganizationId(ctx, scope, orgID, cursor, filter) if err != nil { return nil, fmt.Errorf("cannot list thirdParties: %w", err) } diff --git a/pkg/trust/third_party_service.go b/pkg/trust/third_party_service.go index 1da4d2937..e0b175fa5 100644 --- a/pkg/trust/third_party_service.go +++ b/pkg/trust/third_party_service.go @@ -58,15 +58,13 @@ func (s ThirdPartyService) ListForOrganizationId( scope coredata.Scoper, organizationID gid.GID, cursor *page.Cursor[coredata.ThirdPartyOrderField], + filter *coredata.ThirdPartyFilter, ) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) { var thirdParties coredata.ThirdParties err := s.svc.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil) - err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter) if err != nil { return fmt.Errorf("cannot load thirdParties: %w", err) @@ -86,6 +84,7 @@ func (s ThirdPartyService) CountForTrustCenterId( ctx context.Context, scope coredata.Scoper, trustCenterID gid.GID, + filter *coredata.ThirdPartyFilter, ) (int, error) { var count int @@ -98,8 +97,6 @@ func (s ThirdPartyService) CountForTrustCenterId( } thirdParties := &coredata.ThirdParties{} - showOnTrustCenter := true - filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil) count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter) if err != nil {