Scope sub-third-parties per parent

Replace the many-to-many junction table with a direct
parent_third_party_id foreign key on third_parties. Each
sub-third-party now belongs to exactly one parent, making
duplicates across parents independent entities.

Replace the firstLevel boolean with an integer level field
(1 = direct, 2+ = parent level + 1) to support arbitrary
nesting depth.

Remove the createThirdPartyThirdPartyMapping and
deleteThirdPartyThirdPartyMapping mutations, the CLI
link/unlink commands, and the corresponding MCP tools.
Creating a child third party now just requires passing
parentThirdPartyId on the existing createThirdParty mutation.

The frontend walks the parentThirdParty chain to build
display names like "Name (Ancestor1/Ancestor2)" and shows
clickable ancestor links on the detail page.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-29 16:22:54 +02:00
parent ec858e58df
commit b6781d3de0
36 changed files with 1276 additions and 1192 deletions

View File

@@ -198,7 +198,7 @@ input ThirdPartyOrder
}
input ThirdPartyFilter {
firstLevel: Boolean
level: Int
query: String
}
@@ -303,7 +303,10 @@ type ThirdParty implements Node {
legalName: String
websiteUrl: String
showOnTrustCenter: Boolean!
firstLevel: Boolean!
level: Int!
parentThirdParty: ThirdParty @goField(forceResolver: true)
ancestors: [ThirdParty!]! @goField(forceResolver: true)
childThirdParties(
first: Int
@@ -506,12 +509,6 @@ extend type Mutation {
publishThirdPartyList(
input: PublishThirdPartyListInput!
): PublishThirdPartyListPayload!
createThirdPartyThirdPartyMapping(
input: CreateThirdPartyThirdPartyMappingInput!
): CreateThirdPartyThirdPartyMappingPayload!
deleteThirdPartyThirdPartyMapping(
input: DeleteThirdPartyThirdPartyMappingInput!
): DeleteThirdPartyThirdPartyMappingPayload!
}
input PublishThirdPartyListInput {
@@ -546,7 +543,7 @@ input CreateThirdPartyInput {
termsOfServiceUrl: String
businessOwnerId: ID
securityOwnerId: ID
firstLevel: Boolean
parentThirdPartyId: ID
}
input UpdateThirdPartyInput {
@@ -571,7 +568,6 @@ input UpdateThirdPartyInput {
businessOwnerId: ID @goField(omittable: true)
securityOwnerId: ID @goField(omittable: true)
showOnTrustCenter: Boolean
firstLevel: Boolean
}
input DeleteThirdPartyInput {
@@ -755,21 +751,3 @@ type CreateThirdPartyRiskAssessmentPayload {
type VetThirdPartyPayload {
thirdParty: ThirdParty!
}
input CreateThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type CreateThirdPartyThirdPartyMappingPayload {
thirdPartyEdge: ThirdPartyEdge!
}
input DeleteThirdPartyThirdPartyMappingInput {
parentThirdPartyId: ID!
childThirdPartyId: ID!
}
type DeleteThirdPartyThirdPartyMappingPayload {
removedThirdPartyId: ID!
}

View File

@@ -1291,15 +1291,15 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
var (
firstLevel *bool
query *string
level *int
query *string
)
if filter != nil {
firstLevel = filter.FirstLevel
level = filter.Level
query = filter.Query
}
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, firstLevel, query)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query)
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil {

View File

@@ -33,6 +33,12 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
return nil, err
}
if input.ParentThirdPartyID != nil {
if _, err := r.authorize(ctx, *input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate); err != nil {
return nil, err
}
}
thirdParty, err := r.probo.ThirdParties.Create(
ctx, scope,
probo.CreateThirdPartyRequest{
@@ -56,7 +62,7 @@ func (r *mutationResolver) CreateThirdParty(ctx context.Context, input types.Cre
BusinessOwnerID: input.BusinessOwnerID,
SecurityOwnerID: input.SecurityOwnerID,
Countries: input.Countries,
FirstLevel: input.FirstLevel,
ParentThirdPartyID: input.ParentThirdPartyID,
},
)
if err != nil {
@@ -108,7 +114,6 @@ 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,
},
)
@@ -603,46 +608,6 @@ 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 {
@@ -913,6 +878,53 @@ func (r *thirdPartyResolver) SecurityOwner(ctx context.Context, obj *types.Third
return types.NewProfile(securityOwner), nil
}
// ParentThirdParty is the resolver for the parentThirdParty field.
func (r *thirdPartyResolver) ParentThirdParty(ctx context.Context, obj *types.ThirdParty) (*types.ThirdParty, error) {
if obj.ParentThirdParty == nil {
return nil, nil
}
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
parent, err := loaders.ThirdParty.Load(ctx, obj.ParentThirdParty.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, gqlutils.NotFound(ctx, err)
}
r.logger.ErrorCtx(ctx, "cannot load parent third party", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewThirdParty(parent), nil
}
// Ancestors is the resolver for the ancestors field.
func (r *thirdPartyResolver) Ancestors(ctx context.Context, obj *types.ThirdParty) ([]*types.ThirdParty, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyGet)
if err != nil {
return nil, err
}
ancestors, err := r.probo.ThirdParties.GetAncestors(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load ancestors", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
result := make([]*types.ThirdParty, len(ancestors))
for i, a := range ancestors {
result[i] = types.NewThirdParty(a)
}
return result, 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)

View File

@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
WebsiteURL: v.WebsiteURL,
Category: v.Category,
ShowOnTrustCenter: v.ShowOnTrustCenter,
FirstLevel: v.FirstLevel,
Level: v.Level,
Countries: v.Countries,
UpdatedAt: v.UpdatedAt,
CreatedAt: v.CreatedAt,
@@ -104,5 +104,11 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
}
}
if v.ParentThirdPartyID != nil {
object.ParentThirdParty = &ThirdParty{
ID: *v.ParentThirdPartyID,
}
}
return object
}