diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index c599e12a9..319916fa2 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -19,16 +19,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay" import { PageHeader } from "#/components/PageHeader/PageHeader"; -import { SubprocessorCategorySection } from "./_components/SubprocessorCategorySection"; +import type { SubprocessorsPage_query$key } from "./__generated__/SubprocessorsPage_query.graphql"; +import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql"; +import type { SubprocessorsPageRefetchQuery } from "./__generated__/SubprocessorsPageRefetchQuery.graphql"; import type { SubprocessorNode } from "./_components/SubprocessorCategorySection"; +import { SubprocessorCategorySection } 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: String, $category: SubprocessorCategory, $country: CountryCode) { diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx index fcb9947c3..460c3cdae 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx @@ -15,11 +15,11 @@ import { useEffect, useRef } from "react"; import { useQueryLoader } from "react-relay"; -import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage"; -import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton"; +import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql"; import { toQueryVariables } from "./_lib/toQueryVariables"; import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters"; -import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql"; +import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage"; +import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton"; export default function SubprocessorsPageLoader() { const filters = useSubprocessorFilters(); diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx index 0c8ff9d23..117e72860 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx @@ -12,11 +12,11 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { TextField } from "@probo/ui/src/v2/form/TextField"; 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"; @@ -27,21 +27,14 @@ import { useSubprocessorSearch } from "../_lib/useSubprocessorSearch"; 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). +// Facet data: the distinct categories and countries actually present across the +// trust center's published subprocessors, used to populate the filter dropdowns +// (server-computed so the options never dead-end on an empty result). const subprocessorsToolbarFragment = graphql` fragment SubprocessorsToolbar_query on Query { currentTrustCenter @required(action: THROW) { - allSubprocessors: subprocessors(first: 250) { - edges { - node { - id - category - countries - } - } - } + subprocessorCategories + subprocessorCountries } } `; @@ -59,17 +52,15 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) { const { category, country, setCategory, setCountry } = useSubprocessorFilters(); const [queryInput, setQueryInput] = useSubprocessorSearch(); - const nodes = data.currentTrustCenter.allSubprocessors.edges.map(edge => edge.node); + const { subprocessorCategories, subprocessorCountries } = data.currentTrustCenter; 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]); + return [...subprocessorCategories].sort((a, b) => t(`categories.${a}.label`).localeCompare(t(`categories.${b}.label`))); + }, [subprocessorCategories, 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 [...subprocessorCountries].sort((a, b) => countryLabel(a).localeCompare(countryLabel(b))); + }, [subprocessorCountries, countryLabel]); return (
diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index e4c924932..8a333350b 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -780,6 +780,78 @@ WHERE return count, nil } +func (v *ThirdParties) LoadDistinctTrustCenterCategoriesByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) ([]ThirdPartyCategory, error) { + q := ` +SELECT DISTINCT + category +FROM + third_parties +WHERE + %s + AND organization_id = @organization_id + AND show_on_trust_center = true +ORDER BY + category ASC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query thirdParty categories: %w", err) + } + + categories, err := pgx.CollectRows(rows, pgx.RowTo[ThirdPartyCategory]) + if err != nil { + return nil, fmt.Errorf("cannot collect thirdParty categories: %w", err) + } + + return categories, nil +} + +func (v *ThirdParties) LoadDistinctTrustCenterCountriesByOrganizationID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + organizationID gid.GID, +) ([]CountryCode, error) { + q := ` +SELECT DISTINCT + unnest(countries) AS country +FROM + third_parties +WHERE + %s + AND organization_id = @organization_id + AND show_on_trust_center = true +ORDER BY + country ASC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.NamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query thirdParty countries: %w", err) + } + + countries, err := pgx.CollectRows(rows, pgx.RowTo[CountryCode]) + if err != nil { + return nil, fmt.Errorf("cannot collect thirdParty countries: %w", err) + } + + return countries, nil +} + func (v *ThirdParties) LoadByOrganizationID( ctx context.Context, conn pg.Querier, diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index 749b5e2de..f3d9b4705 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -33,6 +33,11 @@ type TrustCenter implements Node { filter: SubprocessorFilter ): SubprocessorConnection! @goField(forceResolver: true) + subprocessorCategories: [SubprocessorCategory!]! + @goField(forceResolver: true) + + subprocessorCountries: [CountryCode!]! @goField(forceResolver: true) + references( first: Int after: CursorKey diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/trust/v1/trust_center_resolvers.go index 48a10f1ba..349333071 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/trust/v1/trust_center_resolvers.go @@ -831,6 +831,34 @@ func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.Trus return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID, thirdPartyFilter), nil } +// SubprocessorCategories is the resolver for the subprocessorCategories field. +func (r *trustCenterResolver) SubprocessorCategories(ctx context.Context, obj *types.TrustCenter) ([]coredata.ThirdPartyCategory, error) { + compliancePage := compliancepage.CompliancePageFromContext(ctx) + scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) + + categories, err := r.trust.ThirdParties.ListDistinctTrustCenterCategoriesForOrganizationID(ctx, scope, obj.Organization.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list subprocessor categories", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return categories, nil +} + +// SubprocessorCountries is the resolver for the subprocessorCountries field. +func (r *trustCenterResolver) SubprocessorCountries(ctx context.Context, obj *types.TrustCenter) ([]coredata.CountryCode, error) { + compliancePage := compliancepage.CompliancePageFromContext(ctx) + scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID) + + countries, err := r.trust.ThirdParties.ListDistinctTrustCenterCountriesForOrganizationID(ctx, scope, obj.Organization.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list subprocessor countries", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return countries, nil +} + // References is the resolver for the references field. func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterReferenceConnection, error) { compliancePage := compliancepage.CompliancePageFromContext(ctx) diff --git a/pkg/trust/third_party_service.go b/pkg/trust/third_party_service.go index e0b175fa5..016eeb3b0 100644 --- a/pkg/trust/third_party_service.go +++ b/pkg/trust/third_party_service.go @@ -80,6 +80,64 @@ func (s ThirdPartyService) ListForOrganizationId( return page.NewPage(thirdParties, cursor), nil } +func (s ThirdPartyService) ListDistinctTrustCenterCategoriesForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) ([]coredata.ThirdPartyCategory, error) { + var categories []coredata.ThirdPartyCategory + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + thirdParties := &coredata.ThirdParties{} + + result, err := thirdParties.LoadDistinctTrustCenterCategoriesByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot load thirdParty categories: %w", err) + } + + categories = result + + return nil + }, + ) + if err != nil { + return nil, err + } + + return categories, nil +} + +func (s ThirdPartyService) ListDistinctTrustCenterCountriesForOrganizationID( + ctx context.Context, + scope coredata.Scoper, + organizationID gid.GID, +) ([]coredata.CountryCode, error) { + var countries []coredata.CountryCode + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + thirdParties := &coredata.ThirdParties{} + + result, err := thirdParties.LoadDistinctTrustCenterCountriesByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot load thirdParty countries: %w", err) + } + + countries = result + + return nil + }, + ) + if err != nil { + return nil, err + } + + return countries, nil +} + func (s ThirdPartyService) CountForTrustCenterId( ctx context.Context, scope coredata.Scoper,