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 {