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

@@ -1,108 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package link
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const linkMutation = `
mutation($input: CreateThirdPartyThirdPartyMappingInput!) {
createThirdPartyThirdPartyMapping(input: $input) {
thirdPartyEdge {
node {
id
name
}
}
}
}
`
type linkResponse struct {
CreateThirdPartyThirdPartyMapping struct {
ThirdPartyEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"thirdPartyEdge"`
} `json:"createThirdPartyThirdPartyMapping"`
}
func NewCmdLink(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "link <parent-id> <child-id>",
Short: "Link a child thirdParty to a parent thirdParty",
Example: ` # Link a child third_party to a parent
prb thirdParty link <parent-id> <child-id>`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
data, err := client.Do(
linkMutation,
map[string]any{
"input": map[string]any{
"parentThirdPartyId": args[0],
"childThirdPartyId": args[1],
},
},
)
if err != nil {
return err
}
var resp linkResponse
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("cannot parse response: %w", err)
}
v := resp.CreateThirdPartyThirdPartyMapping.ThirdPartyEdge.Node
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Linked thirdParty %s (%s) as child of %s\n",
v.ID,
v.Name,
args[0],
)
return nil
},
}
return cmd
}

View File

@@ -55,12 +55,12 @@ type thirdParty struct {
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagFirstLevel bool
flagOutput *string
flagOrg string
flagLimit int
flagOrderBy string
flagOrderDir string
flagLevel int
flagOutput *string
)
cmd := &cobra.Command{
@@ -108,9 +108,13 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
"id": flagOrg,
}
if cmd.Flags().Changed("first-level") {
if cmd.Flags().Changed("level") {
if flagLevel < 1 {
return fmt.Errorf("invalid --level value %d: must be greater than or equal to 1", flagLevel)
}
variables["filter"] = map[string]any{
"first-level": flagFirstLevel,
"level": flagLevel,
}
}
@@ -195,7 +199,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of thirdParties to list")
cmd.Flags().StringVar(&flagOrderBy, "order-by", "", "Order by field (NAME, CREATED_AT, UPDATED_AT)")
cmd.Flags().StringVar(&flagOrderDir, "order-direction", "DESC", "Sort direction (ASC, DESC)")
cmd.Flags().BoolVar(&flagFirstLevel, "first-level", false, "Filter by first-level thirdParties only")
cmd.Flags().IntVar(&flagLevel, "level", 0, "Filter by third party level (1 = direct, 2+ = indirect)")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd

View File

@@ -19,10 +19,8 @@ import (
"go.probo.inc/probo/pkg/cmd/cmdutil"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/create"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/delete"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/link"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/list"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/publish"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/unlink"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/update"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/vet"
"go.probo.inc/probo/pkg/cmd/thirdpartymgmt/view"
@@ -41,8 +39,6 @@ func NewCmdThirdParty(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(delete.NewCmdDelete(f))
cmd.AddCommand(vet.NewCmdVet(f))
cmd.AddCommand(publish.NewCmdPublish(f))
cmd.AddCommand(link.NewCmdLink(f))
cmd.AddCommand(unlink.NewCmdUnlink(f))
return cmd
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package unlink
import (
"fmt"
"github.com/spf13/cobra"
"go.probo.inc/probo/pkg/cli/api"
"go.probo.inc/probo/pkg/cmd/cmdutil"
)
const unlinkMutation = `
mutation($input: UncreateThirdPartyThirdPartyMappingInput!) {
uncreateThirdPartyThirdPartyMapping(input: $input) {
removedThirdPartyId
}
}
`
func NewCmdUnlink(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "unlink <parent-id> <child-id>",
Short: "Unlink a child thirdParty from a parent thirdParty",
Example: ` # Unlink a child third_party from a parent
prb thirdParty unlink <parent-id> <child-id>`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host, hc, err := cfg.DefaultHost()
if err != nil {
return err
}
client := api.NewClient(
host,
hc.Token,
"/api/console/v1/graphql",
cfg.HTTPTimeoutDuration(),
cmdutil.TokenRefreshOption(cfg, host, hc),
)
_, err = client.Do(
unlinkMutation,
map[string]any{
"input": map[string]any{
"parentThirdPartyId": args[0],
"childThirdPartyId": args[1],
},
},
)
if err != nil {
return err
}
_, _ = fmt.Fprintf(
f.IOStreams.Out,
"Unlinked thirdParty %s from parent %s\n",
args[1],
args[0],
)
return nil
},
}
return cmd
}