Filter trust center subprocessors server-side

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-30 14:40:50 +02:00
parent f1f4c93104
commit c9b74d6de0
20 changed files with 623 additions and 31 deletions

View File

@@ -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)
}

View File

@@ -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
)`
}

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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.

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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 {