Add third-party self-referential relations

Introduce a self-referential many-to-many relation table so a
third party can have child third parties. Each relation is
directional (parent to child); both directions can coexist as
independent rows.

Add a first_level boolean on third_parties (default true) with
a filter on the list page that defaults to showing only
first-level third parties.

Frontend adds a "Third Parties" tab on the detail page where
users can link existing third parties or create new ones from
the common third party catalog (created as non-first-level).
The list page gets a First Level/All toggle filter.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-19 19:13:16 +02:00
parent 8ff68798fb
commit b6b1e801b1
37 changed files with 2399 additions and 45 deletions

View File

@@ -331,6 +331,7 @@ type Organization implements Node {
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
filter: ThirdPartyFilter
): ThirdPartyConnection! @goField(forceResolver: true)
thirdPartiesDocument: Document @goField(forceResolver: true)

View File

@@ -177,6 +177,10 @@ input ThirdPartyOrder
field: ThirdPartyOrderField!
}
input ThirdPartyFilter {
firstLevel: Boolean
}
input ThirdPartyComplianceReportOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ThirdPartyComplianceReportOrderBy"
@@ -269,6 +273,16 @@ type ThirdParty implements Node {
legalName: String
websiteUrl: String
showOnTrustCenter: Boolean!
firstLevel: Boolean!
childThirdParties(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ThirdPartyOrder
): ThirdPartyConnection! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -460,6 +474,12 @@ extend type Mutation {
publishThirdPartyList(
input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload!
createThirdPartyThirdPartyMapping(
input: CreateThirdPartyThirdPartyMappingInput!
): CreateThirdPartyThirdPartyMappingPayload!
deleteThirdPartyThirdPartyMapping(
input: DeleteThirdPartyThirdPartyMappingInput!
): DeleteThirdPartyThirdPartyMappingPayload!
}
input PublishThirdPartyListInput {
@@ -494,6 +514,7 @@ input CreateThirdPartyInput {
termsOfServiceUrl: String
businessOwnerId: ID
securityOwnerId: ID
firstLevel: Boolean
}
input UpdateThirdPartyInput {
@@ -518,6 +539,7 @@ input UpdateThirdPartyInput {
businessOwnerId: ID @goField(omittable: true)
securityOwnerId: ID @goField(omittable: true)
showOnTrustCenter: Boolean
firstLevel: Boolean
}
input DeleteThirdPartyInput {
@@ -709,3 +731,21 @@ type AssessThirdPartyPayload {
report: String!
subprocessors: [ThirdPartySubprocessor!]!
}
input CreateThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type CreateThirdPartyThirdPartyMappingPayload {
thirdPartyEdge: ThirdPartyEdge!
}
input DeleteThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type DeleteThirdPartyThirdPartyMappingPayload {
removedThirdPartyId: ID!
}

View File

@@ -1271,7 +1271,7 @@ func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Org
}
// ThirdParties is the resolver for the thirdParties field.
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy, filter *types.ThirdPartyFilter) (*types.ThirdPartyConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil {
return nil, err
@@ -1291,7 +1291,12 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil)
var firstLevel *bool
if filter != nil {
firstLevel = filter.FirstLevel
}
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel)
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil {

View File

@@ -55,6 +55,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
BusinessOwnerID: input.BusinessOwnerID,
SecurityOwnerID: input.SecurityOwnerID,
Countries: input.Countries,
FirstLevel: input.FirstLevel,
},
)
if err != nil {
@@ -106,6 +107,7 @@ func (r *mutationResolver) UpdateThirdParty(ctx context.Context, input types.Upd
BusinessOwnerID: gqlutils.UnwrapOmittable(input.BusinessOwnerID),
SecurityOwnerID: gqlutils.UnwrapOmittable(input.SecurityOwnerID),
ShowOnTrustCenter: input.ShowOnTrustCenter,
FirstLevel: input.FirstLevel,
Countries: input.Countries,
},
)
@@ -594,6 +596,46 @@ func (r *mutationResolver) PublishThirdPartyList(ctx context.Context, input type
}, nil
}
// CreateThirdPartyThirdPartyMapping is the resolver for the linkThirdPartyThirdParty field.
func (r *mutationResolver) CreateThirdPartyThirdPartyMapping(ctx context.Context, input types.CreateThirdPartyThirdPartyMappingInput) (*types.CreateThirdPartyThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
if err != nil {
return nil, err
}
childThirdParty, err := r.probo.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot create third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateThirdPartyThirdPartyMappingPayload{
ThirdPartyEdge: types.NewThirdPartyEdge(childThirdParty, coredata.ThirdPartyOrderFieldName),
}, nil
}
// DeleteThirdPartyThirdPartyMapping is the resolver for the deleteThirdPartyThirdPartyMapping field.
func (r *mutationResolver) DeleteThirdPartyThirdPartyMapping(ctx context.Context, input types.DeleteThirdPartyThirdPartyMappingInput) (*types.DeleteThirdPartyThirdPartyMappingPayload, error) {
scope, err := r.authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
if err != nil {
return nil, err
}
if err := r.probo.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
r.logger.ErrorCtx(ctx, "cannot delete third party mapping", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteThirdPartyThirdPartyMappingPayload{
RemovedThirdPartyID: input.ChildThirdPartyID,
}, nil
}
// Organization is the resolver for the organization field.
func (r *thirdPartyResolver) Organization(ctx context.Context, obj *types.ThirdParty) (*types.Organization, error) {
if _, err := r.authorize(ctx, obj.ID, probo.ActionOrganizationGet); err != nil {
@@ -830,6 +872,35 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
return types.NewProfile(securityOwner), nil
}
// ChildThirdParties is the resolver for the childThirdParties field.
func (r *thirdPartyResolver) ChildThirdParties(ctx context.Context, obj *types.ThirdParty, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ThirdPartyOrderBy) (*types.ThirdPartyConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyRelationList)
if err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.ThirdPartyOrderField]{
Field: coredata.ThirdPartyOrderFieldName,
Direction: page.OrderDirectionAsc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ThirdPartyOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.probo.ThirdParties.ListForParentThirdPartyID(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list child third parties", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdPartyConnection(page, r, obj.ID), nil
}
// Permission is the resolver for the permission field.
func (r *thirdPartyResolver) Permission(ctx context.Context, obj *types.ThirdParty, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -963,6 +1034,19 @@ func (r *thirdPartyConnectionResolver) TotalCount(ctx context.Context, obj *type
return 0, gqlutils.Internal(ctx)
}
return count, nil
case *thirdPartyResolver:
if _, err := r.authorize(ctx, obj.ParentID, probo.ActionThirdPartyRelationList); err != nil {
return 0, err
}
count, err := r.probo.ThirdParties.CountForParentThirdPartyID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count child third parties", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
@@ -1145,3 +1229,15 @@ type thirdPartyContactResolver struct{ *Resolver }
type thirdPartyDataPrivacyAgreementResolver struct{ *Resolver }
type thirdPartyRiskAssessmentResolver struct{ *Resolver }
type thirdPartyServiceResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
/*
func (r *mutationResolver) UncreateThirdPartyThirdPartyMapping(ctx context.Context, input types.UncreateThirdPartyThirdPartyMappingInput) (*types.UncreateThirdPartyThirdPartyMappingPayload, error) {
panic(fmt.Errorf("not implemented: UncreateThirdPartyThirdPartyMapping - uncreateThirdPartyThirdPartyMapping"))
}
*/

View File

@@ -84,6 +84,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
FirstLevel: v.FirstLevel,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,