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

@@ -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,