From c25e7c6c4091f694e7a780a345345893fe825d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 10 Jun 2026 14:36:38 +0200 Subject: [PATCH] Add explicit third-party import from catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- pkg/coredata/tracker_pattern.go | 49 +++++++++++ pkg/probo/third_party_service.go | 140 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index c773b8dfb..82b7cb1ed 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -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, diff --git a/pkg/probo/third_party_service.go b/pkg/probo/third_party_service.go index af221042b..712812283 100644 --- a/pkg/probo/third_party_service.go +++ b/pkg/probo/third_party_service.go @@ -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,