From 31c4a0b39c5ecb4dc7d6cb0c77e1156d85eeba2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 10 Jun 2026 11:10:41 +0200 Subject: [PATCH] Add proboctl catalog upsert, link, and describe commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators previously had no way to curate the global tracker catalog beyond inspection and banner-scoped resets. Add three proboctl commands backed by small coredata helpers: - common-third-party upsert: create or update a vendor keyed by slug, with partial-merge so an unset flag never blanks an existing column. - common-tracker-pattern link/unlink: repoint catalog rows at a common third party (re-arming enrichment and remapping the uncategorised org trackers so the mapping worker re-resolves the vendor) or detach them. Unlinking skips enrichment and remap since there is no new vendor. - common-tracker-pattern set-description: write a description, mark the row enriched, and backfill linked org patterns lacking one. Signed-off-by: Émile Ré --- pkg/coredata/common_tracker_pattern.go | 33 +++ pkg/coredata/tracker_pattern.go | 43 ++++ .../commonthirdparty/commonthirdparty.go | 1 + pkg/proboctl/commonthirdparty/upsert.go | 206 ++++++++++++++++++ .../commontrackerpattern.go | 3 + pkg/proboctl/commontrackerpattern/link.go | 163 ++++++++++++++ .../commontrackerpattern/set_description.go | 109 +++++++++ pkg/proboctl/commontrackerpattern/unlink.go | 125 +++++++++++ 8 files changed, 683 insertions(+) create mode 100644 pkg/proboctl/commonthirdparty/upsert.go create mode 100644 pkg/proboctl/commontrackerpattern/link.go create mode 100644 pkg/proboctl/commontrackerpattern/set_description.go create mode 100644 pkg/proboctl/commontrackerpattern/unlink.go diff --git a/pkg/coredata/common_tracker_pattern.go b/pkg/coredata/common_tracker_pattern.go index 3f242fcdc..b06e21203 100644 --- a/pkg/coredata/common_tracker_pattern.go +++ b/pkg/coredata/common_tracker_pattern.go @@ -835,6 +835,39 @@ ORDER BY pattern ASC return ids, nil } +// RelinkCommonThirdPartyByIDs repoints the given common tracker patterns +// at a different common third party (or unlinks them when thirdPartyID is +// nil). It only touches the catalog rows; callers re-arm enrichment and +// remap the org-scoped tracker patterns separately. Returns the number of +// rows updated. +func (ps *CommonTrackerPatterns) RelinkCommonThirdPartyByIDs( + ctx context.Context, + tx pg.Tx, + ids []gid.GID, + thirdPartyID *gid.GID, +) (int64, error) { + q := ` +UPDATE common_tracker_patterns +SET + common_third_party_id = @third_party_id, + updated_at = NOW() +WHERE + id = ANY(@ids) +` + + args := pgx.StrictNamedArgs{ + "ids": ids, + "third_party_id": thirdPartyID, + } + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return 0, fmt.Errorf("cannot relink common tracker pattern third party: %w", err) + } + + return result.RowsAffected(), nil +} + // RequestEnrichmentByIDs arms enrichment on the given common tracker // patterns by stamping enrichment_requested_at, which is the only column // the enrichment worker claims on. Already-enriched rows are re-processed diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index 04454e8cd..c773b8dfb 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -1383,6 +1383,49 @@ WHERE return result.RowsAffected(), nil } +// RequestMappingForUncategorisedByCommonTrackerPatternIDs re-arms mapping +// on the uncategorised org tracker patterns linked to the given common +// tracker patterns: it clears their resolved org third party and stamps +// mapping_requested_at so the mapping worker re-resolves the vendor from +// the catalog row's (now changed) common third party. Like the +// description backfill it is a global catalog operation, so it is +// intentionally not tenant-scoped. Excluded patterns and patterns in +// user-chosen categories are left untouched - only the uncategorised +// category is remapped, matching the reset-trackers philosophy. The +// cookie_categories subquery is used only for filtering. Returns the +// number of org patterns re-armed. +func (tps *TrackerPatterns) RequestMappingForUncategorisedByCommonTrackerPatternIDs( + ctx context.Context, + tx pg.Tx, + commonIDs []gid.GID, +) (int64, error) { + q := ` +UPDATE tracker_patterns +SET + third_party_id = NULL, + mapping_requested_at = NOW(), + updated_at = NOW() +WHERE + common_tracker_pattern_id = ANY(@common_ids) + AND excluded = false + AND cookie_category_id IN ( + SELECT id FROM cookie_categories WHERE kind = @uncategorised_kind + ) +` + + args := pgx.StrictNamedArgs{ + "common_ids": commonIDs, + "uncategorised_kind": CookieCategoryKindUncategorised, + } + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return 0, fmt.Errorf("cannot request mapping for uncategorised tracker patterns: %w", err) + } + + return result.RowsAffected(), nil +} + // ResetAndRequestMappingByCookieCategoryID detaches every pattern in the // given category from its catalog row, org third party, and copied // description, then re-arms mapping. Operators run this (via proboctl) on diff --git a/pkg/proboctl/commonthirdparty/commonthirdparty.go b/pkg/proboctl/commonthirdparty/commonthirdparty.go index 60ec5f350..b3402b66d 100644 --- a/pkg/proboctl/commonthirdparty/commonthirdparty.go +++ b/pkg/proboctl/commonthirdparty/commonthirdparty.go @@ -39,6 +39,7 @@ func NewCmdCommonThirdParty(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(newCmdList(f)) cmd.AddCommand(newCmdShow(f)) cmd.AddCommand(newCmdDomains(f)) + cmd.AddCommand(newCmdUpsert(f)) return cmd } diff --git a/pkg/proboctl/commonthirdparty/upsert.go b/pkg/proboctl/commonthirdparty/upsert.go new file mode 100644 index 000000000..eb3443bb4 --- /dev/null +++ b/pkg/proboctl/commonthirdparty/upsert.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 commonthirdparty + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/proboctl/cmdutil" + "go.probo.inc/probo/pkg/slug" +) + +func newCmdUpsert(f *cmdutil.Factory) *cobra.Command { + var ( + flagName string + flagSlug string + flagCategory string + flagWebsiteURL string + flagLegalName string + flagHeadquarterAddress string + flagPrivacyPolicyURL string + flagServiceLevelAgreementURL string + flagServiceSoftwareAgreementURL string + flagDataProcessingAgreementURL string + flagBusinessAssociateAgreementURL string + flagSubprocessorsListURL string + flagStatusPageURL string + flagTermsOfServiceURL string + flagSecurityPageURL string + flagTrustPageURL string + flagCertifications []string + flagDryRun bool + ) + + cmd := &cobra.Command{ + Use: "upsert", + Short: "Create or update a common third party in the global catalog", + Long: "Insert a new common third party or update an existing one keyed by " + + "slug. Only --name and --category are required; every other field is " + + "updated only when its flag is passed, so an existing row's other " + + "columns are preserved.", + Args: cobra.NoArgs, + } + + cmd.Flags().StringVar(&flagName, "name", "", "Display name (required)") + cmd.Flags().StringVar(&flagSlug, "slug", "", "Slug key (default: derived from --name)") + cmd.Flags().StringVar(&flagCategory, "category", "", "Third party category (required)") + cmd.Flags().StringVar(&flagWebsiteURL, "website-url", "", "Website URL") + cmd.Flags().StringVar(&flagLegalName, "legal-name", "", "Legal name") + cmd.Flags().StringVar(&flagHeadquarterAddress, "headquarter-address", "", "Headquarter address") + cmd.Flags().StringVar(&flagPrivacyPolicyURL, "privacy-policy-url", "", "Privacy policy URL") + cmd.Flags().StringVar(&flagServiceLevelAgreementURL, "service-level-agreement-url", "", "Service level agreement URL") + cmd.Flags().StringVar(&flagServiceSoftwareAgreementURL, "service-software-agreement-url", "", "Service software agreement URL") + cmd.Flags().StringVar(&flagDataProcessingAgreementURL, "data-processing-agreement-url", "", "Data processing agreement URL") + cmd.Flags().StringVar(&flagBusinessAssociateAgreementURL, "business-associate-agreement-url", "", "Business associate agreement URL") + cmd.Flags().StringVar(&flagSubprocessorsListURL, "subprocessors-list-url", "", "Subprocessors list URL") + cmd.Flags().StringVar(&flagStatusPageURL, "status-page-url", "", "Status page URL") + cmd.Flags().StringVar(&flagTermsOfServiceURL, "terms-of-service-url", "", "Terms of service URL") + cmd.Flags().StringVar(&flagSecurityPageURL, "security-page-url", "", "Security page URL") + cmd.Flags().StringVar(&flagTrustPageURL, "trust-page-url", "", "Trust page URL") + cmd.Flags().StringSliceVar(&flagCertifications, "certifications", nil, "Certifications (repeatable)") + cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Print the resulting row without writing") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("category") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + category := coredata.ThirdPartyCategory(flagCategory) + if !category.IsValid() { + return fmt.Errorf("invalid --category value %q", flagCategory) + } + + partySlug := flagSlug + if partySlug == "" { + partySlug = slug.Make(flagName) + } + + if partySlug == "" { + return fmt.Errorf("cannot derive a slug from --name %q; pass --slug explicitly", flagName) + } + + pgClient, err := f.PgClient() + if err != nil { + return err + } + + out := f.IOStreams.Out + + var ( + party coredata.CommonThirdParty + inserted bool + ) + + if err := pgClient.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + now := time.Now() + + existing := coredata.CommonThirdParty{} + + err := existing.LoadBySlug(ctx, tx, partySlug) + switch { + case err == nil: + party = existing + case errors.Is(err, coredata.ErrResourceNotFound): + party = coredata.CommonThirdParty{ + ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType), + Slug: partySlug, + Certifications: []string{}, + CreatedAt: now, + } + default: + return fmt.Errorf("cannot load common third party by slug: %w", err) + } + + party.Name = flagName + party.Category = category + party.Slug = partySlug + party.UpdatedAt = now + + applyFlag(cmd, "website-url", &party.WebsiteURL, flagWebsiteURL) + applyFlag(cmd, "legal-name", &party.LegalName, flagLegalName) + applyFlag(cmd, "headquarter-address", &party.HeadquarterAddress, flagHeadquarterAddress) + applyFlag(cmd, "privacy-policy-url", &party.PrivacyPolicyURL, flagPrivacyPolicyURL) + applyFlag(cmd, "service-level-agreement-url", &party.ServiceLevelAgreementURL, flagServiceLevelAgreementURL) + applyFlag(cmd, "service-software-agreement-url", &party.ServiceSoftwareAgreementURL, flagServiceSoftwareAgreementURL) + applyFlag(cmd, "data-processing-agreement-url", &party.DataProcessingAgreementURL, flagDataProcessingAgreementURL) + applyFlag(cmd, "business-associate-agreement-url", &party.BusinessAssociateAgreementURL, flagBusinessAssociateAgreementURL) + applyFlag(cmd, "subprocessors-list-url", &party.SubprocessorsListURL, flagSubprocessorsListURL) + applyFlag(cmd, "status-page-url", &party.StatusPageURL, flagStatusPageURL) + applyFlag(cmd, "terms-of-service-url", &party.TermsOfServiceURL, flagTermsOfServiceURL) + applyFlag(cmd, "security-page-url", &party.SecurityPageURL, flagSecurityPageURL) + applyFlag(cmd, "trust-page-url", &party.TrustPageURL, flagTrustPageURL) + + if cmd.Flags().Changed("certifications") { + party.Certifications = flagCertifications + } + + if flagDryRun { + return nil + } + + inserted, err = party.Upsert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot upsert common third party: %w", err) + } + + return nil + }, + ); err != nil { + return err + } + + if flagDryRun { + _, _ = fmt.Fprintf(out, "Would upsert common third party %q (slug %q, category %s).\n", party.Name, party.Slug, party.Category) + return nil + } + + action := "Updated" + if inserted { + action = "Created" + } + + _, _ = fmt.Fprintf(out, "%s common third party %s (%q, slug %q).\n", action, party.ID.String(), party.Name, party.Slug) + + return nil + } + + return cmd +} + +// applyFlag overrides target with value when the named flag was passed. +// An empty value clears the column; an unset flag leaves it untouched, so +// an upsert can update one field without blanking the rest of the row. +func applyFlag(cmd *cobra.Command, name string, target **string, value string) { + if !cmd.Flags().Changed(name) { + return + } + + if value == "" { + *target = nil + return + } + + *target = &value +} diff --git a/pkg/proboctl/commontrackerpattern/commontrackerpattern.go b/pkg/proboctl/commontrackerpattern/commontrackerpattern.go index 1dd25def3..97fcb7d17 100644 --- a/pkg/proboctl/commontrackerpattern/commontrackerpattern.go +++ b/pkg/proboctl/commontrackerpattern/commontrackerpattern.go @@ -39,6 +39,9 @@ func NewCmdCommonTrackerPattern(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(newCmdShow(f)) cmd.AddCommand(newCmdReenrich(f)) cmd.AddCommand(newCmdStats(f)) + cmd.AddCommand(newCmdLink(f)) + cmd.AddCommand(newCmdUnlink(f)) + cmd.AddCommand(newCmdSetDescription(f)) return cmd } diff --git a/pkg/proboctl/commontrackerpattern/link.go b/pkg/proboctl/commontrackerpattern/link.go new file mode 100644 index 000000000..a66490e97 --- /dev/null +++ b/pkg/proboctl/commontrackerpattern/link.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 commontrackerpattern + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/proboctl/cmdutil" +) + +func newCmdLink(f *cmdutil.Factory) *cobra.Command { + var ( + flagIDs []string + flagLinkedBanner string + flagLinkedOrg string + flagCommonThirdParty string + flagTrackerType string + flagKeyword string + flagState string + flagWithoutDescription bool + flagTo string + flagDryRun bool + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "link", + Short: "Link common tracker patterns to a common third party", + Long: "Point selected common tracker patterns at a common third party " + + "(--to-common-third-party). The catalog rows are re-armed for enrichment " + + "so a description is re-derived for the vendor, and the uncategorised org " + + "tracker patterns linked to them are remapped (org third party cleared, " + + "mapping re-armed) so the mapping worker re-resolves the vendor. " + + "User-categorised and excluded org patterns are left untouched. Selection " + + "mirrors 'reenrich'. To detach patterns, use 'unlink'.", + Args: cobra.NoArgs, + } + + cmd.Flags().StringSliceVar(&flagIDs, "id", nil, "Common tracker pattern GID(s) to link (repeatable)") + cmd.Flags().StringVar(&flagLinkedBanner, "linked-banner", "", "Select catalog rows linked to a cookie banner's patterns (GID)") + cmd.Flags().StringVar(&flagLinkedOrg, "linked-org", "", "Select catalog rows linked to an organization's patterns (GID)") + cmd.Flags().StringVar(&flagCommonThirdParty, "common-third-party", "", "Select patterns currently linked to a common third party (slug or GID)") + cmd.Flags().StringVar(&flagTrackerType, "tracker-type", "", "Filter selected patterns by tracker type") + cmd.Flags().StringVar(&flagKeyword, "keyword", "", "Filter selected patterns by a pattern/description substring") + cmd.Flags().StringVar(&flagState, "state", "", "Filter selected patterns by enrichment state (queued, enriched, unenriched)") + cmd.Flags().BoolVar(&flagWithoutDescription, "without-description", false, "Only patterns with a blank description") + cmd.Flags().StringVar(&flagTo, "to-common-third-party", "", "Target common third party to link to (slug or GID)") + cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Print the selected patterns without linking") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation") + + _ = cmd.MarkFlagRequired("to-common-third-party") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pgClient, err := f.PgClient() + if err != nil { + return err + } + + ids, err := resolveReenrichIDs( + ctx, + pgClient, + flagIDs, + flagLinkedBanner, + flagLinkedOrg, + flagCommonThirdParty, + flagTrackerType, + flagKeyword, + flagState, + flagWithoutDescription, + ) + if err != nil { + return err + } + + out := f.IOStreams.Out + + if len(ids) == 0 { + _, _ = fmt.Fprintln(out, "No common tracker patterns matched the selection.") + return nil + } + + if flagDryRun { + _, _ = fmt.Fprintf(out, "Would link %d common tracker pattern(s) to %s.\n", len(ids), flagTo) + printSample(out, ids) + + return nil + } + + if !flagYes { + return fmt.Errorf("about to link %d pattern(s) to %s; pass --yes to proceed or --dry-run to preview", len(ids), flagTo) + } + + var ( + linked int64 + requeued int64 + remapped int64 + ) + + if err := pgClient.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + thirdPartyID, err := resolveCommonThirdPartyID(ctx, tx, flagTo) + if err != nil { + return err + } + + var ps coredata.CommonTrackerPatterns + + linked, err = ps.RelinkCommonThirdPartyByIDs(ctx, tx, ids, &thirdPartyID) + if err != nil { + return err + } + + requeued, err = ps.RequestEnrichmentByIDs(ctx, tx, ids) + if err != nil { + return err + } + + var tps coredata.TrackerPatterns + + remapped, err = tps.RequestMappingForUncategorisedByCommonTrackerPatternIDs(ctx, tx, ids) + if err != nil { + return err + } + + return nil + }, + ); err != nil { + return fmt.Errorf("cannot link common tracker patterns: %w", err) + } + + _, _ = fmt.Fprintf( + out, + "Linked %d pattern(s) to %s, re-queued %d for enrichment, remapped %d uncategorised org tracker pattern(s).\n", + linked, + flagTo, + requeued, + remapped, + ) + + return nil + } + + return cmd +} diff --git a/pkg/proboctl/commontrackerpattern/set_description.go b/pkg/proboctl/commontrackerpattern/set_description.go new file mode 100644 index 000000000..cfef1a3af --- /dev/null +++ b/pkg/proboctl/commontrackerpattern/set_description.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 commontrackerpattern + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/proboctl/cmdutil" +) + +func newCmdSetDescription(f *cmdutil.Factory) *cobra.Command { + var ( + flagDescription string + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "set-description ", + Short: "Set a common tracker pattern's description and backfill org patterns", + Long: "Write a description on a common tracker pattern and mark it enriched so " + + "the enrichment worker leaves it alone, then backfill the description onto " + + "every linked org tracker pattern that does not already have one.", + Args: cobra.ExactArgs(1), + } + + cmd.Flags().StringVar(&flagDescription, "description", "", "Description to set (required)") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation") + + _ = cmd.MarkFlagRequired("description") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if flagDescription == "" { + return fmt.Errorf("--description must not be empty") + } + + id, err := gid.ParseGID(args[0]) + if err != nil { + return fmt.Errorf("invalid GID %q: %w", args[0], err) + } + + if !flagYes { + return fmt.Errorf("about to set the description on %s; pass --yes to proceed", id.String()) + } + + pgClient, err := f.PgClient() + if err != nil { + return err + } + + out := f.IOStreams.Out + + var backfilled int64 + + if err := pgClient.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var pattern coredata.CommonTrackerPattern + if err := pattern.LoadByID(ctx, tx, id); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("no common tracker pattern found for %q", args[0]) + } + + return fmt.Errorf("cannot load common tracker pattern: %w", err) + } + + if err := pattern.SetEnriched(ctx, tx, flagDescription, nil); err != nil { + return fmt.Errorf("cannot set common tracker pattern description: %w", err) + } + + var tps coredata.TrackerPatterns + + backfilled, err = tps.BackfillDescriptionByCommonTrackerPatternID(ctx, tx, id, flagDescription) + if err != nil { + return err + } + + return nil + }, + ); err != nil { + return err + } + + _, _ = fmt.Fprintf(out, "Set description on %s, backfilled %d org tracker pattern(s).\n", id.String(), backfilled) + + return nil + } + + return cmd +} diff --git a/pkg/proboctl/commontrackerpattern/unlink.go b/pkg/proboctl/commontrackerpattern/unlink.go new file mode 100644 index 000000000..9bd8bf35c --- /dev/null +++ b/pkg/proboctl/commontrackerpattern/unlink.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 commontrackerpattern + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/proboctl/cmdutil" +) + +func newCmdUnlink(f *cmdutil.Factory) *cobra.Command { + var ( + flagIDs []string + flagLinkedBanner string + flagLinkedOrg string + flagCommonThirdParty string + flagTrackerType string + flagKeyword string + flagState string + flagWithoutDescription bool + flagDryRun bool + flagYes bool + ) + + cmd := &cobra.Command{ + Use: "unlink", + Short: "Unlink common tracker patterns from any common third party", + Long: "Detach selected common tracker patterns from their common third party. " + + "Unlinking only clears the catalog link: there is no new vendor to " + + "re-enrich a description for or to remap org patterns onto, so neither " + + "enrichment nor org remapping is triggered. Selection mirrors 'reenrich'.", + Args: cobra.NoArgs, + } + + cmd.Flags().StringSliceVar(&flagIDs, "id", nil, "Common tracker pattern GID(s) to unlink (repeatable)") + cmd.Flags().StringVar(&flagLinkedBanner, "linked-banner", "", "Select catalog rows linked to a cookie banner's patterns (GID)") + cmd.Flags().StringVar(&flagLinkedOrg, "linked-org", "", "Select catalog rows linked to an organization's patterns (GID)") + cmd.Flags().StringVar(&flagCommonThirdParty, "common-third-party", "", "Select patterns currently linked to a common third party (slug or GID)") + cmd.Flags().StringVar(&flagTrackerType, "tracker-type", "", "Filter selected patterns by tracker type") + cmd.Flags().StringVar(&flagKeyword, "keyword", "", "Filter selected patterns by a pattern/description substring") + cmd.Flags().StringVar(&flagState, "state", "", "Filter selected patterns by enrichment state (queued, enriched, unenriched)") + cmd.Flags().BoolVar(&flagWithoutDescription, "without-description", false, "Only patterns with a blank description") + cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Print the selected patterns without unlinking") + cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + pgClient, err := f.PgClient() + if err != nil { + return err + } + + ids, err := resolveReenrichIDs( + ctx, + pgClient, + flagIDs, + flagLinkedBanner, + flagLinkedOrg, + flagCommonThirdParty, + flagTrackerType, + flagKeyword, + flagState, + flagWithoutDescription, + ) + if err != nil { + return err + } + + out := f.IOStreams.Out + + if len(ids) == 0 { + _, _ = fmt.Fprintln(out, "No common tracker patterns matched the selection.") + return nil + } + + if flagDryRun { + _, _ = fmt.Fprintf(out, "Would unlink %d common tracker pattern(s).\n", len(ids)) + printSample(out, ids) + + return nil + } + + if !flagYes { + return fmt.Errorf("about to unlink %d pattern(s); pass --yes to proceed or --dry-run to preview", len(ids)) + } + + var unlinked int64 + + if err := pgClient.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var ps coredata.CommonTrackerPatterns + + unlinked, err = ps.RelinkCommonThirdPartyByIDs(ctx, tx, ids, nil) + + return err + }, + ); err != nil { + return fmt.Errorf("cannot unlink common tracker patterns: %w", err) + } + + _, _ = fmt.Fprintf(out, "Unlinked %d pattern(s) from any common third party.\n", unlinked) + + return nil + } + + return cmd +}