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:
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.FirstLevel, nil)
|
||||
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil)
|
||||
|
||||
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
|
||||
if err != nil {
|
||||
@@ -6215,32 +6215,6 @@ func (r *Resolver) MoveTrackerResourceToCategoryTool(ctx context.Context, req *m
|
||||
return nil, types.MoveTrackerResourceToCategoryOutput{TrackerResource: types.NewTrackerResource(result.TrackerResource)}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) CreateThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.CreateThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationCreate)
|
||||
if err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if _, err := r.proboSvc.ThirdParties.CreateThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot create third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.CreateThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) DeleteThirdPartyThirdPartyMappingTool(ctx context.Context, req *mcp.CallToolRequest, input *types.DeleteThirdPartyThirdPartyMappingInput) (*mcp.CallToolResult, types.DeleteThirdPartyThirdPartyMappingOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationDelete)
|
||||
if err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, err
|
||||
}
|
||||
|
||||
if err := r.proboSvc.ThirdParties.DeleteThirdPartyMapping(ctx, scope, input.ParentThirdPartyID, input.ChildThirdPartyID); err != nil {
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, fmt.Errorf("cannot delete third party mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.DeleteThirdPartyThirdPartyMappingOutput{}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) ListChildThirdPartiesTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListChildThirdPartiesInput) (*mcp.CallToolResult, types.ListChildThirdPartiesOutput, error) {
|
||||
scope, err := r.Authorize(ctx, input.ParentThirdPartyID, probo.ActionThirdPartyRelationList)
|
||||
if err != nil {
|
||||
|
||||
@@ -641,9 +641,9 @@ components:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Filter by first-level status
|
||||
level:
|
||||
type: integer
|
||||
description: Filter by level
|
||||
order_by:
|
||||
$ref: "#/components/schemas/ThirdPartyOrderBy"
|
||||
description: ThirdParty order by
|
||||
@@ -667,38 +667,6 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/ThirdParty"
|
||||
|
||||
CreateThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
CreateThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingInput:
|
||||
type: object
|
||||
required:
|
||||
- parent_third_party_id
|
||||
- child_third_party_id
|
||||
properties:
|
||||
parent_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Parent third party ID
|
||||
child_third_party_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Child third party ID
|
||||
|
||||
DeleteThirdPartyThirdPartyMappingOutput:
|
||||
type: object
|
||||
|
||||
ListChildThirdPartiesInput:
|
||||
type: object
|
||||
required:
|
||||
@@ -737,7 +705,7 @@ components:
|
||||
- name
|
||||
- organization_id
|
||||
- category
|
||||
- first_level
|
||||
- level
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
@@ -861,9 +829,9 @@ components:
|
||||
- string
|
||||
- "null"
|
||||
description: Trust page URL
|
||||
first_level:
|
||||
type: boolean
|
||||
description: Whether this is a first-level third party
|
||||
level:
|
||||
type: integer
|
||||
description: Level of this third party (1 = direct, 2+ = indirect)
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -12055,22 +12023,6 @@ tools:
|
||||
$ref: "#/components/schemas/ListThirdPartiesInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/ListThirdPartiesOutput"
|
||||
- name: createThirdPartyThirdPartyMapping
|
||||
description: Link a child third party to a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/CreateThirdPartyThirdPartyMappingOutput"
|
||||
- name: deleteThirdPartyThirdPartyMapping
|
||||
description: Unlink a child third party from a parent third party
|
||||
hints:
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/DeleteThirdPartyThirdPartyMappingOutput"
|
||||
- name: listChildThirdParties
|
||||
description: List child third parties linked to a parent third party
|
||||
hints:
|
||||
|
||||
@@ -86,7 +86,7 @@ func NewThirdParty(v *coredata.ThirdParty) *ThirdParty {
|
||||
TermsOfServiceURL: v.TermsOfServiceURL,
|
||||
SecurityPageURL: v.SecurityPageURL,
|
||||
TrustPageURL: v.TrustPageURL,
|
||||
FirstLevel: v.FirstLevel,
|
||||
Level: v.Level,
|
||||
CreatedAt: v.CreatedAt,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user