Add explicit third-party import from catalog

With the tracker-mapping worker no longer materializing org third
parties, add the deliberate action that does: ThirdPartyService.Import
FromCommon seeds an org ThirdParty from a CommonThirdParty catalog entry
or returns the one the organization already imported, making it
idempotent on the (organization_id, common_third_party_id) pair.

On both the create and reuse paths it backfills tracker_patterns.third_
party_id for the organization's unlinked patterns whose catalog row
resolves to the same common third party, via the new TrackerPatterns.Link
ThirdPartyByCommonThirdPartyID. Patterns that previously surfaced the
catalog entry then surface the managed org vendor in the trackers UI and
the tracker-policy document. Only unlinked patterns are touched, so the
backfill is idempotent and picks up newly detected patterns on re-import.

End-to-end coverage (idempotency and pattern backfill) lands with the
GraphQL mutation in a following commit.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-10 14:36:38 +02:00
parent 0f5ac03e70
commit c25e7c6c40
2 changed files with 189 additions and 0 deletions

View File

@@ -1143,6 +1143,55 @@ WHERE
return nil
}
// LinkThirdPartyByCommonThirdPartyID points the organization's unlinked
// tracker patterns at an org ThirdParty when their catalog row resolves
// to the given common third party. It is the backfill the explicit
// import action runs so patterns that previously surfaced the catalog
// (CommonThirdParty) entry now surface the managed org ThirdParty. Only
// patterns with no third_party_id are touched, so it is idempotent and
// never overrides an existing link. The common_tracker_patterns
// subquery only narrows the WHERE clause, keeping the resolution in the
// database.
func (tps *TrackerPatterns) LinkThirdPartyByCommonThirdPartyID(
ctx context.Context,
tx pg.Tx,
scope Scoper,
organizationID gid.GID,
commonThirdPartyID gid.GID,
thirdPartyID gid.GID,
) error {
q := `
UPDATE tracker_patterns
SET
third_party_id = @third_party_id,
updated_at = NOW()
WHERE
%s
AND organization_id = @organization_id
AND third_party_id IS NULL
AND common_tracker_pattern_id IN (
SELECT id FROM common_tracker_patterns
WHERE common_third_party_id = @common_third_party_id
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"common_third_party_id": commonThirdPartyID,
"third_party_id": thirdPartyID,
}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot link tracker patterns to third party: %w", err)
}
return nil
}
func (tp *TrackerPattern) LoadNextForMappingForUpdateSkipLocked(
ctx context.Context,
tx pg.Tx,

View File

@@ -16,6 +16,7 @@ package probo
import (
"context"
"errors"
"fmt"
"time"
@@ -88,8 +89,22 @@ type (
BusinessImpact coredata.BusinessImpact
Notes *string
}
ImportThirdPartyFromCommonRequest struct {
OrganizationID gid.GID
CommonThirdPartyID gid.GID
}
)
func (r *ImportThirdPartyFromCommonRequest) Validate() error {
v := validator.New()
v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(r.CommonThirdPartyID, "common_third_party_id", validator.Required(), validator.GID(coredata.CommonThirdPartyEntityType))
return v.Error()
}
func (cvr *CreateThirdPartyRequest) Validate() error {
v := validator.New()
@@ -669,6 +684,131 @@ func (s ThirdPartyService) Create(
return thirdParty, nil
}
// ImportFromCommon creates an org ThirdParty seeded from the global
// CommonThirdParty catalog entry, or returns the existing one when the
// organization already imported it (idempotent on the
// (organization_id, common_third_party_id) pair). It then backfills
// tracker_patterns.third_party_id for the organization's unlinked
// patterns whose catalog row resolves to the same common third party, so
// the trackers UI and tracker-policy document surface the managed vendor
// instead of the catalog entry. The backfill runs on both the create and
// reuse paths because new patterns may have been detected since a prior
// import; it only touches patterns with no existing link. Returns the
// org ThirdParty and whether it was newly created.
func (s ThirdPartyService) ImportFromCommon(
ctx context.Context, scope coredata.Scoper,
req ImportThirdPartyFromCommonRequest,
) (*coredata.ThirdParty, bool, error) {
if err := req.Validate(); err != nil {
return nil, false, err
}
thirdParty := &coredata.ThirdParty{}
created := false
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, conn pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
existing := &coredata.ThirdParty{}
err := existing.LoadByOrganizationIDAndCommonThirdPartyID(
ctx,
conn,
scope,
organization.ID,
req.CommonThirdPartyID,
)
switch {
case err == nil:
*thirdParty = *existing
case errors.Is(err, coredata.ErrResourceNotFound):
commonParty := &coredata.CommonThirdParty{}
if err := commonParty.LoadByID(ctx, conn, req.CommonThirdPartyID); err != nil {
return fmt.Errorf("cannot load common third party: %w", err)
}
now := time.Now()
commonID := commonParty.ID
certifications := commonParty.Certifications
if certifications == nil {
certifications = []string{}
}
*thirdParty = coredata.ThirdParty{
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: organization.ID,
CommonThirdPartyID: &commonID,
Name: commonParty.Name,
Category: commonParty.Category,
HeadquarterAddress: commonParty.HeadquarterAddress,
LegalName: commonParty.LegalName,
WebsiteURL: commonParty.WebsiteURL,
PrivacyPolicyURL: commonParty.PrivacyPolicyURL,
ServiceLevelAgreementURL: commonParty.ServiceLevelAgreementURL,
DataProcessingAgreementURL: commonParty.DataProcessingAgreementURL,
BusinessAssociateAgreementURL: commonParty.BusinessAssociateAgreementURL,
SubprocessorsListURL: commonParty.SubprocessorsListURL,
Certifications: certifications,
Countries: coredata.CountryCodes{},
StatusPageURL: commonParty.StatusPageURL,
TermsOfServiceURL: commonParty.TermsOfServiceURL,
SecurityPageURL: commonParty.SecurityPageURL,
TrustPageURL: commonParty.TrustPageURL,
ShowOnTrustCenter: false,
FirstLevel: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := thirdParty.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert third party: %w", err)
}
created = true
if err := webhook.InsertData(
ctx,
conn,
scope,
organization.ID,
coredata.WebhookEventTypeThirdPartyCreated,
webhooktypes.NewThirdParty(thirdParty),
); err != nil {
return fmt.Errorf("cannot insert webhook event: %w", err)
}
default:
return fmt.Errorf("cannot load third party by common id: %w", err)
}
var patterns coredata.TrackerPatterns
if err := patterns.LinkThirdPartyByCommonThirdPartyID(
ctx,
conn,
scope,
organization.ID,
req.CommonThirdPartyID,
thirdParty.ID,
); err != nil {
return fmt.Errorf("cannot link tracker patterns to imported third party: %w", err)
}
return nil
},
)
if err != nil {
return nil, false, err
}
return thirdParty, created, nil
}
func (s ThirdPartyService) CountForAssetID(
ctx context.Context, scope coredata.Scoper,
assetID gid.GID,