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

@@ -0,0 +1,251 @@
-- 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.
-- ADD COLUMN ... DEFAULT 1 already backfills every existing row to level 1, so
-- only the sub-third-parties (first_level = false) need correcting to level 2.
-- Scoping the UPDATE to them leaves first-level rows entirely untouched.
ALTER TABLE third_parties ADD COLUMN level integer NOT NULL DEFAULT 1;
UPDATE third_parties SET level = 2 WHERE first_level = false;
ALTER TABLE third_parties ALTER COLUMN level DROP DEFAULT;
-- Keep first_level around instead of dropping it so the change is reversible and
-- old code paths keep working during a rolling deploy. New inserts no longer set
-- it, so give it a default to satisfy the NOT NULL constraint.
ALTER TABLE third_parties ALTER COLUMN first_level SET DEFAULT true;
ALTER TABLE third_parties ADD COLUMN parent_third_party_id text REFERENCES third_parties(id) ON DELETE CASCADE;
-- Pick each non-first-level child's primary parent: the parent it will be
-- re-parented to in place. Only first_level = false rows are eligible — a
-- first-level row stays a root (its links become copies below, never an
-- in-place move). Prefer a top-level (first_level = true) parent so the child
-- anchors to a real root, then fall back to the relationship that was created
-- first. IDs are text GIDs, so MIN() would order them lexicographically rather
-- than chronologically — sort by the junction row's created_at instead, with
-- the parent id as a deterministic tie-break.
CREATE TEMP TABLE _tp_primary_parent AS
SELECT DISTINCT ON (tpr.child_third_party_id)
tpr.child_third_party_id,
tpr.parent_third_party_id
FROM third_party_third_parties tpr
JOIN third_parties parent ON parent.id = tpr.parent_third_party_id
JOIN third_parties child ON child.id = tpr.child_third_party_id
WHERE child.first_level = false
ORDER BY
tpr.child_third_party_id,
parent.first_level DESC,
tpr.created_at ASC,
tpr.parent_third_party_id ASC;
-- Assign every existing child its primary parent in place. Updating the row in
-- place preserves its ID and therefore every dependent record (risk
-- assessments, services, contacts, measure links, …) — nothing is deleted or
-- re-keyed for the common one-parent case.
-- Restricted to first_level = false: only sub-third-parties may gain a parent;
-- a top-level (first_level = true) row must stay at level 1 with no parent even
-- if it appears in the junction table by accident.
UPDATE third_parties tp
SET parent_third_party_id = primary_parent.parent_third_party_id
FROM _tp_primary_parent AS primary_parent
WHERE tp.id = primary_parent.child_third_party_id
AND tp.first_level = false;
-- Re-qualify the names of the reparented sub-third-parties to the hierarchy
-- convention used by the console/vetting ("base (root/.../parent)"), matching
-- the copies created below. Names are rebuilt from base names (trailing " (…)"
-- stripped) so already-qualified rows are normalized rather than double-suffixed.
WITH RECURSIVE tp_path AS (
SELECT
id,
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
FROM third_parties
WHERE parent_third_party_id IS NULL
UNION ALL
SELECT
c.id,
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
FROM third_parties c
JOIN tp_path p ON p.id = c.parent_third_party_id
)
UPDATE third_parties tp
SET name = trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')'
FROM tp_path pp
WHERE pp.id = tp.parent_third_party_id
AND tp.first_level = false;
-- Every junction row that is NOT an in-place reparent becomes its own copy: the
-- extra parents of a non-first-level child, plus every link whose child is
-- first_level = true (the root is preserved and a fresh sub-third-party copy is
-- created under the parent). Generate a fresh GID for each (child, parent) pair.
-- generate_gid() and parse_tenant_id() are defined in migration 20250420T120000Z.
CREATE TEMP TABLE _tp_copy_map AS
SELECT
tpr.child_third_party_id AS old_id,
tpr.parent_third_party_id AS parent_id,
generate_gid(parse_tenant_id(tp.tenant_id), 7) AS new_id
FROM third_party_third_parties tpr
JOIN third_parties tp ON tp.id = tpr.child_third_party_id
WHERE NOT EXISTS (
SELECT 1
FROM _tp_primary_parent pp
WHERE pp.child_third_party_id = tpr.child_third_party_id
AND pp.parent_third_party_id = tpr.parent_third_party_id
);
-- Resolve each third party's hierarchy-qualified path (root → self, base names
-- joined by "/"), mirroring the console/vetting naming convention. A copy under
-- parent P is named "<child base> (<path of P>)", e.g. "Google Workspace (Probo)".
-- The base name strips any trailing " (…)" suffix exactly like the app regex.
WITH RECURSIVE tp_path AS (
SELECT
id,
level,
trim(regexp_replace(name, '\s*\([^)]*\)\s*$', '')) AS path
FROM third_parties
WHERE parent_third_party_id IS NULL
UNION ALL
SELECT
c.id,
c.level,
p.path || '/' || trim(regexp_replace(c.name, '\s*\([^)]*\)\s*$', '')) AS path
FROM third_parties c
JOIN tp_path p ON p.id = c.parent_third_party_id
)
INSERT INTO third_parties (
id,
tenant_id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
created_at,
updated_at
)
SELECT
m.new_id,
tp.tenant_id,
tp.organization_id,
-- The parent is always an existing row that needs no remapping.
m.parent_id,
-- A copy is a relationship/subprocessor record, not the canonical
-- catalog-linked vendor. Leave common_third_party_id NULL so the original
-- root stays the single third party resolved for a common catalog entry
-- (e.g. cookie-banner tracker mapping by common_third_party_id).
NULL,
-- Hierarchy-qualified name: child base name suffixed with the parent's path.
trim(regexp_replace(tp.name, '\s*\([^)]*\)\s*$', '')) || ' (' || pp.path || ')',
tp.description,
tp.category,
tp.headquarter_address,
tp.legal_name,
tp.website_url,
tp.privacy_policy_url,
tp.service_level_agreement_url,
tp.data_processing_agreement_url,
tp.business_associate_agreement_url,
tp.subprocessors_list_url,
tp.certifications,
tp.countries,
tp.business_owner_profile_id,
tp.security_owner_profile_id,
tp.status_page_url,
tp.terms_of_service_url,
tp.security_page_url,
tp.trust_page_url,
tp.show_on_trust_center,
-- Copies are sub-third-parties (level >= 2), never first-level roots.
false,
-- Level follows the parent, not the copied child: a first-level child
-- (level 1) copied under Probo (level 1) becomes a level-2 sub-third-party.
pp.level + 1,
tp.created_at,
tp.updated_at
FROM _tp_copy_map m
JOIN third_parties tp ON tp.id = m.old_id
JOIN tp_path pp ON pp.id = m.parent_id;
INSERT INTO third_party_services (
tenant_id,
id,
organization_id,
third_party_id,
name,
description,
created_at,
updated_at
)
SELECT
tp.tenant_id,
generate_gid(parse_tenant_id(tp.tenant_id), 30),
s.organization_id,
m.new_id,
s.name,
s.description,
s.created_at,
s.updated_at
FROM _tp_copy_map m
JOIN third_parties tp ON tp.id = m.old_id
JOIN third_party_services s ON s.third_party_id = m.old_id;
INSERT INTO third_party_contacts (
tenant_id,
id,
organization_id,
third_party_id,
full_name,
email,
phone,
role,
created_at,
updated_at
)
SELECT
tp.tenant_id,
generate_gid(parse_tenant_id(tp.tenant_id), 26),
c.organization_id,
m.new_id,
c.full_name,
c.email,
c.phone,
c.role,
c.created_at,
c.updated_at
FROM _tp_copy_map m
JOIN third_parties tp ON tp.id = m.old_id
JOIN third_party_contacts c ON c.third_party_id = m.old_id;
DROP TABLE _tp_copy_map;
DROP TABLE _tp_primary_parent;
DROP TABLE third_party_third_parties;

View File

@@ -28,6 +28,11 @@ import (
"go.probo.inc/probo/pkg/page"
)
// MaxThirdPartyLevel is the deepest sub-third-party nesting allowed. Level 1 is
// a direct third party; each descendant adds one level, so the chain may not go
// beyond level 4.
const MaxThirdPartyLevel = 4
func (v ThirdParty) GetGeneratedDocumentID(
ctx context.Context,
conn pg.Querier,
@@ -140,6 +145,7 @@ type (
ThirdParty struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
ParentThirdPartyID *gid.GID `db:"parent_third_party_id"`
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
Name string `db:"name"`
Description *string `db:"description"`
@@ -161,7 +167,7 @@ type (
SecurityPageURL *string `db:"security_page_url"`
TrustPageURL *string `db:"trust_page_url"`
ShowOnTrustCenter bool `db:"show_on_trust_center"`
FirstLevel bool `db:"first_level"`
Level int `db:"level"`
VettingStatus *ThirdPartyVettingStatus `db:"vetting_status"`
VettingWebsiteURL *string `db:"vetting_website_url"`
VettingProcedure *string `db:"vetting_procedure"`
@@ -236,6 +242,7 @@ func (v *ThirdParty) LoadByID(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -257,7 +264,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -308,6 +315,7 @@ func (v *ThirdParty) LoadByIDForUpdate(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -329,7 +337,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -382,6 +390,7 @@ func (v *ThirdParty) LoadByNameAndOrganizationID(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -403,7 +412,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -448,16 +457,18 @@ LIMIT 1;
return nil
}
func (v *ThirdParties) LoadByIDs(
func (v *ThirdParty) LoadByNameAndParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyIDs []gid.GID,
name string,
parentThirdPartyID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -479,7 +490,84 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
third_parties
WHERE
%s
AND parent_third_party_id = @parent_third_party_id
AND name = @name
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"parent_third_party_id": parentThirdPartyID,
"name": name,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query thirdParty by name and parent: %w", err)
}
defer rows.Close()
thirdParty, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ThirdParty])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect thirdParty: %w", err)
}
*v = thirdParty
return nil
}
func (v *ThirdParties) LoadByIDs(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -525,6 +613,7 @@ INSERT INTO
tenant_id,
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -546,7 +635,7 @@ INSERT INTO
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -559,6 +648,7 @@ VALUES (
@tenant_id,
@third_party_id,
@organization_id,
@parent_third_party_id,
@common_third_party_id,
@name,
@description,
@@ -580,7 +670,7 @@ VALUES (
@security_page_url,
@trust_page_url,
@show_on_trust_center,
@first_level,
@level,
@vetting_status,
@vetting_website_url,
@vetting_procedure,
@@ -595,6 +685,7 @@ VALUES (
"tenant_id": scope.GetTenantID(),
"third_party_id": v.ID,
"organization_id": v.OrganizationID,
"parent_third_party_id": v.ParentThirdPartyID,
"common_third_party_id": v.CommonThirdPartyID,
"name": v.Name,
"description": v.Description,
@@ -616,7 +707,7 @@ VALUES (
"security_page_url": v.SecurityPageURL,
"trust_page_url": v.TrustPageURL,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"level": v.Level,
"vetting_status": v.VettingStatus,
"vetting_website_url": v.VettingWebsiteURL,
"vetting_procedure": v.VettingProcedure,
@@ -690,11 +781,13 @@ func (v *ThirdParties) LoadAllByOrganizationID(
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
filter *ThirdPartyFilter,
) error {
q := `
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -716,7 +809,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -729,12 +822,14 @@ FROM
WHERE
%s
AND organization_id = @organization_id
AND %s
ORDER BY name ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
@@ -763,6 +858,7 @@ func (v *ThirdParties) LoadByOrganizationID(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -784,7 +880,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -834,6 +930,7 @@ SET
name = @name,
description = @description,
category = @category,
parent_third_party_id = @parent_third_party_id,
headquarter_address = @headquarter_address,
legal_name = @legal_name,
website_url = @website_url,
@@ -851,7 +948,7 @@ SET
business_owner_profile_id = @business_owner_profile_id,
security_owner_profile_id = @security_owner_profile_id,
show_on_trust_center = @show_on_trust_center,
first_level = @first_level,
level = @level,
vetting_status = @vetting_status,
vetting_website_url = @vetting_website_url,
vetting_procedure = @vetting_procedure,
@@ -870,6 +967,7 @@ WHERE %s
"name": v.Name,
"description": v.Description,
"category": v.Category,
"parent_third_party_id": v.ParentThirdPartyID,
"headquarter_address": v.HeadquarterAddress,
"legal_name": v.LegalName,
"website_url": v.WebsiteURL,
@@ -887,7 +985,7 @@ WHERE %s
"business_owner_profile_id": v.BusinessOwnerID,
"security_owner_profile_id": v.SecurityOwnerID,
"show_on_trust_center": v.ShowOnTrustCenter,
"first_level": v.FirstLevel,
"level": v.Level,
"vetting_status": v.VettingStatus,
"vetting_website_url": v.VettingWebsiteURL,
"vetting_procedure": v.VettingProcedure,
@@ -996,6 +1094,7 @@ WITH vend AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1017,7 +1116,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1035,6 +1134,7 @@ WITH vend AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1056,7 +1156,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1142,6 +1242,7 @@ WITH vend AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1163,7 +1264,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1181,6 +1282,7 @@ WITH vend AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1202,7 +1304,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1248,6 +1350,7 @@ WITH vend AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1269,7 +1372,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1287,6 +1390,7 @@ WITH vend AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1308,7 +1412,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1355,6 +1459,7 @@ WITH vend AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1376,7 +1481,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1394,6 +1499,7 @@ WITH vend AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1415,7 +1521,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1530,6 +1636,7 @@ WITH vend AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1551,7 +1658,7 @@ WITH vend AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1569,6 +1676,7 @@ WITH vend AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1590,7 +1698,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1634,6 +1742,7 @@ func (v *ThirdParty) LoadByOrganizationIDAndCommonThirdPartyID(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1655,7 +1764,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1754,6 +1863,7 @@ WITH tps AS (
v.id,
v.tenant_id,
v.organization_id,
v.parent_third_party_id,
v.common_third_party_id,
v.name,
v.description,
@@ -1775,7 +1885,7 @@ WITH tps AS (
v.security_page_url,
v.trust_page_url,
v.show_on_trust_center,
v.first_level,
v.level,
v.vetting_status,
v.vetting_website_url,
v.vetting_procedure,
@@ -1793,6 +1903,7 @@ WITH tps AS (
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -1814,7 +1925,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
@@ -1847,3 +1958,254 @@ WHERE %s
return nil
}
func (v *ThirdParties) CountByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
third_parties
WHERE
%s
AND parent_third_party_id = @parent_third_party_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
maps.Copy(args, scope.SQLArguments())
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count child third parties: %w", err)
}
return count, nil
}
func (v *ThirdParties) LoadAllAncestorsByThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
thirdPartyID gid.GID,
) error {
q := `
WITH RECURSIVE ancestor_chain AS (
SELECT
tp.id,
tp.tenant_id,
tp.organization_id,
tp.parent_third_party_id,
tp.common_third_party_id,
tp.name,
tp.description,
tp.category,
tp.headquarter_address,
tp.legal_name,
tp.website_url,
tp.privacy_policy_url,
tp.service_level_agreement_url,
tp.data_processing_agreement_url,
tp.business_associate_agreement_url,
tp.subprocessors_list_url,
tp.certifications,
tp.countries,
tp.business_owner_profile_id,
tp.security_owner_profile_id,
tp.status_page_url,
tp.terms_of_service_url,
tp.security_page_url,
tp.trust_page_url,
tp.show_on_trust_center,
tp.level,
tp.vetting_status,
tp.vetting_website_url,
tp.vetting_procedure,
tp.vetting_processing_started_at,
tp.vetting_error_message,
tp.created_at,
tp.updated_at,
1 AS depth
FROM third_parties tp
WHERE %s
AND tp.id = (
SELECT parent_third_party_id
FROM third_parties
WHERE id = @third_party_id
)
UNION ALL
SELECT
tp.id,
tp.tenant_id,
tp.organization_id,
tp.parent_third_party_id,
tp.common_third_party_id,
tp.name,
tp.description,
tp.category,
tp.headquarter_address,
tp.legal_name,
tp.website_url,
tp.privacy_policy_url,
tp.service_level_agreement_url,
tp.data_processing_agreement_url,
tp.business_associate_agreement_url,
tp.subprocessors_list_url,
tp.certifications,
tp.countries,
tp.business_owner_profile_id,
tp.security_owner_profile_id,
tp.status_page_url,
tp.terms_of_service_url,
tp.security_page_url,
tp.trust_page_url,
tp.show_on_trust_center,
tp.level,
tp.vetting_status,
tp.vetting_website_url,
tp.vetting_procedure,
tp.vetting_processing_started_at,
tp.vetting_error_message,
tp.created_at,
tp.updated_at,
ac.depth + 1
FROM third_parties tp
JOIN ancestor_chain ac ON tp.id = ac.parent_third_party_id
WHERE ac.depth < @max_depth
AND tp.tenant_id = ac.tenant_id
)
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM ancestor_chain
ORDER BY depth DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"third_party_id": thirdPartyID,
"max_depth": MaxThirdPartyLevel,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query ancestors: %w", err)
}
ancestors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
if err != nil {
return fmt.Errorf("cannot collect ancestors: %w", err)
}
*v = ancestors
return nil
}
func (v *ThirdParties) LoadByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
cursor *page.Cursor[ThirdPartyOrderField],
) error {
q := `
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
third_parties
WHERE
%s
AND parent_third_party_id = @parent_third_party_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query child third parties: %w", err)
}
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
if err != nil {
return fmt.Errorf("cannot collect child third parties: %w", err)
}
*v = thirdParties
return nil
}

View File

@@ -21,15 +21,15 @@ import (
type (
ThirdPartyFilter struct {
showOnTrustCenter *bool
firstLevel *bool
level *int
query *string
}
)
func NewThirdPartyFilter(showOnTrustCenter *bool, firstLevel *bool, query *string) *ThirdPartyFilter {
func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter {
return &ThirdPartyFilter{
showOnTrustCenter: showOnTrustCenter,
firstLevel: firstLevel,
level: level,
query: query,
}
}
@@ -38,6 +38,7 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args := pgx.StrictNamedArgs{
"show_on_trust_center": nil,
"filter_query": nil,
"level": nil,
}
if f.showOnTrustCenter != nil {
@@ -48,10 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args["filter_query"] = *f.query
}
if f.firstLevel != nil {
args["first_level"] = *f.firstLevel
} else {
args["first_level"] = nil
if f.level != nil {
args["level"] = *f.level
}
return args
@@ -66,8 +65,8 @@ func (f *ThirdPartyFilter) SQLFragment() string {
ELSE TRUE
END
AND CASE
WHEN @first_level::boolean IS NOT NULL THEN
first_level = @first_level::boolean
WHEN @level::integer IS NOT NULL THEN
level = @level::integer
ELSE TRUE
END
AND CASE

View File

@@ -1,240 +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 coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
ThirdPartyThirdParty struct {
ParentThirdPartyID gid.GID `db:"parent_third_party_id"`
ChildThirdPartyID gid.GID `db:"child_third_party_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
Purpose *string `db:"purpose"`
}
ThirdPartyThirdParties []*ThirdPartyThirdParty
)
func (r *ThirdPartyThirdParty) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error {
q := `
INSERT INTO third_party_third_parties (
parent_third_party_id,
child_third_party_id,
tenant_id,
created_at,
purpose
) VALUES (
@parent_third_party_id,
@child_third_party_id,
@tenant_id,
@created_at,
@purpose
)
ON CONFLICT (parent_third_party_id, child_third_party_id) DO UPDATE SET
purpose = COALESCE(EXCLUDED.purpose, third_party_third_parties.purpose)
`
args := pgx.StrictNamedArgs{
"parent_third_party_id": r.ParentThirdPartyID,
"child_third_party_id": r.ChildThirdPartyID,
"tenant_id": scope.GetTenantID(),
"created_at": r.CreatedAt,
"purpose": r.Purpose,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert third party third party: %w", err)
}
return nil
}
func (r *ThirdPartyThirdParty) Delete(ctx context.Context, conn pg.Tx, scope Scoper) error {
q := `
DELETE FROM third_party_third_parties
WHERE %s
AND parent_third_party_id = @parent_third_party_id
AND child_third_party_id = @child_third_party_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"parent_third_party_id": r.ParentThirdPartyID,
"child_third_party_id": r.ChildThirdPartyID,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}
func (v *ThirdParties) CountByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
) (int, error) {
q := `
WITH children AS (
SELECT
tp.id,
tp.tenant_id
FROM
third_parties tp
INNER JOIN
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
WHERE
tpr.parent_third_party_id = @parent_third_party_id
)
SELECT
COUNT(id)
FROM
children
WHERE %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
maps.Copy(args, scope.SQLArguments())
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count child third parties: %w", err)
}
return count, nil
}
func (v *ThirdParties) LoadByParentThirdPartyID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
parentThirdPartyID gid.GID,
cursor *page.Cursor[ThirdPartyOrderField],
) error {
q := `
WITH children AS (
SELECT
tp.id,
tp.tenant_id,
tp.organization_id,
tp.common_third_party_id,
tp.name,
tp.description,
tp.category,
tp.headquarter_address,
tp.legal_name,
tp.website_url,
tp.privacy_policy_url,
tp.service_level_agreement_url,
tp.data_processing_agreement_url,
tp.business_associate_agreement_url,
tp.subprocessors_list_url,
tp.certifications,
tp.countries,
tp.business_owner_profile_id,
tp.security_owner_profile_id,
tp.status_page_url,
tp.terms_of_service_url,
tp.security_page_url,
tp.trust_page_url,
tp.show_on_trust_center,
tp.first_level,
tp.vetting_status,
tp.vetting_website_url,
tp.vetting_procedure,
tp.vetting_processing_started_at,
tp.vetting_error_message,
tp.created_at,
tp.updated_at
FROM
third_parties tp
INNER JOIN
third_party_third_parties tpr ON tp.id = tpr.child_third_party_id
WHERE
tpr.parent_third_party_id = @parent_third_party_id
)
SELECT
id,
organization_id,
common_third_party_id,
name,
description,
category,
headquarter_address,
legal_name,
website_url,
privacy_policy_url,
service_level_agreement_url,
data_processing_agreement_url,
business_associate_agreement_url,
subprocessors_list_url,
certifications,
countries,
business_owner_profile_id,
security_owner_profile_id,
status_page_url,
terms_of_service_url,
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
vetting_status,
vetting_website_url,
vetting_procedure,
vetting_processing_started_at,
vetting_error_message,
created_at,
updated_at
FROM
children
WHERE %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"parent_third_party_id": parentThirdPartyID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query child third parties: %w", err)
}
thirdParties, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[ThirdParty])
if err != nil {
return fmt.Errorf("cannot collect child third parties: %w", err)
}
*v = thirdParties
return nil
}

View File

@@ -32,6 +32,7 @@ func (v *ThirdParty) LoadNextPendingVettingForUpdateSkipLocked(
SELECT
id,
organization_id,
parent_third_party_id,
common_third_party_id,
name,
description,
@@ -53,7 +54,7 @@ SELECT
security_page_url,
trust_page_url,
show_on_trust_center,
first_level,
level,
vetting_status,
vetting_website_url,
vetting_procedure,