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

@@ -94,7 +94,6 @@ const (
// ThirdPartyRelation actions
ActionThirdPartyRelationCreate = "core:thirdParty-relation:create"
ActionThirdPartyRelationDelete = "core:thirdParty-relation:delete"
ActionThirdPartyRelationList = "core:thirdParty-relation:list"
// ThirdPartyContact actions

View File

@@ -2357,8 +2357,16 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
conn pg.Querier,
organization *coredata.Organization,
) (docgen.ThirdPartyListData, error) {
firstLevel := 1
var thirdParties coredata.ThirdParties
if err := thirdParties.LoadAllByOrganizationID(ctx, conn, scope, organization.ID); err != nil {
if err := thirdParties.LoadAllByOrganizationID(
ctx,
conn,
scope,
organization.ID,
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
); err != nil {
return docgen.ThirdPartyListData{}, fmt.Errorf("cannot load thirdParties: %w", err)
}

View File

@@ -54,7 +54,7 @@ type (
StatusPageURL *string
BusinessOwnerID *gid.GID
SecurityOwnerID *gid.GID
FirstLevel *bool
ParentThirdPartyID *gid.GID
}
UpdateThirdPartyRequest struct {
@@ -79,7 +79,6 @@ type (
BusinessOwnerID **gid.GID
SecurityOwnerID **gid.GID
ShowOnTrustCenter *bool
FirstLevel *bool
}
CreateThirdPartyRiskAssessmentRequest struct {
@@ -398,10 +397,6 @@ func (s ThirdPartyService) Update(
thirdParty.ShowOnTrustCenter = *req.ShowOnTrustCenter
}
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
}
if req.TrustPageURL != nil {
thirdParty.TrustPageURL = *req.TrustPageURL
}
@@ -589,11 +584,7 @@ func (s ThirdPartyService) Create(
StatusPageURL: req.StatusPageURL,
TermsOfServiceURL: req.TermsOfServiceURL,
ShowOnTrustCenter: false,
FirstLevel: true,
}
if req.FirstLevel != nil {
thirdParty.FirstLevel = *req.FirstLevel
Level: 1,
}
err := s.svc.pg.WithTx(
@@ -606,6 +597,29 @@ func (s ThirdPartyService) Create(
thirdParty.OrganizationID = organization.ID
if req.ParentThirdPartyID != nil {
parent := &coredata.ThirdParty{}
if err := parent.LoadByID(ctx, conn, scope, *req.ParentThirdPartyID); err != nil {
return fmt.Errorf("cannot load parent third party: %w", err)
}
if parent.OrganizationID != organization.ID {
return fmt.Errorf("parent third party belongs to a different organization: %w", coredata.ErrResourceNotFound)
}
thirdParty.ParentThirdPartyID = &parent.ID
// The level always follows the parent chain; ignore any
// client-supplied level so it cannot desync from the hierarchy.
thirdParty.Level = parent.Level + 1
}
levelValidator := validator.New()
levelValidator.Check(thirdParty.Level, "level", validator.Max(coredata.MaxThirdPartyLevel))
if err := levelValidator.Error(); err != nil {
return err
}
if req.BusinessOwnerID != nil {
businessOwner := &coredata.MembershipProfile{}
if err := businessOwner.LoadByID(ctx, conn, scope, *req.BusinessOwnerID); err != nil {
@@ -848,69 +862,24 @@ func (s ThirdPartyService) GetByRiskAssessmentID(
return thirdParty, nil
}
func (s ThirdPartyService) CreateThirdPartyMapping(
func (s ThirdPartyService) GetAncestors(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
childThirdPartyID gid.GID,
) (*coredata.ThirdParty, error) {
childThirdParty := &coredata.ThirdParty{}
thirdPartyID gid.GID,
) (coredata.ThirdParties, error) {
var ancestors coredata.ThirdParties
err := s.svc.pg.WithTx(
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Tx) error {
parentThirdParty := &coredata.ThirdParty{}
if err := parentThirdParty.LoadByID(ctx, conn, scope, parentThirdPartyID); err != nil {
return fmt.Errorf("cannot load parent third party: %w", err)
}
if err := childThirdParty.LoadByID(ctx, conn, scope, childThirdPartyID); err != nil {
return fmt.Errorf("cannot load child third party: %w", err)
}
if parentThirdParty.OrganizationID != childThirdParty.OrganizationID {
return fmt.Errorf("cannot create mapping for third parties from different organizations: %w", coredata.ErrResourceNotFound)
}
relation := &coredata.ThirdPartyThirdParty{
ParentThirdPartyID: parentThirdPartyID,
ChildThirdPartyID: childThirdPartyID,
CreatedAt: time.Now(),
}
if err := relation.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot create third party mapping: %w", err)
}
return nil
func(ctx context.Context, conn pg.Querier) error {
return ancestors.LoadAllAncestorsByThirdPartyID(ctx, conn, scope, thirdPartyID)
},
)
if err != nil {
return nil, err
}
return childThirdParty, nil
}
func (s ThirdPartyService) DeleteThirdPartyMapping(
ctx context.Context,
scope coredata.Scoper,
parentThirdPartyID gid.GID,
childThirdPartyID gid.GID,
) error {
return s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
relation := &coredata.ThirdPartyThirdParty{
ParentThirdPartyID: parentThirdPartyID,
ChildThirdPartyID: childThirdPartyID,
}
if err := relation.Delete(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot delete third party mapping: %w", err)
}
return nil
},
)
return ancestors, nil
}
func (s ThirdPartyService) CountForParentThirdPartyID(