From aebb2a1ed0b88a0f92b396a6c0e511eb43707859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 2 Jun 2026 17:31:54 +0200 Subject: [PATCH] Surface common tracker pattern link across APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing tracker_patterns.common_tracker_pattern_id foreign key on the TrackerPattern type so it is possible to tell whether a pattern is linked to the global common-tracker catalog (its description likely came from the seed or the mapping/enrichment agents) or has no link (added manually or inherited). This is a read-only debugging aid for agent-generated descriptions; no migration or write path changes. The field is added in sync across all four API surfaces (GraphQL, MCP, CLI, n8n) plus the console UI, and covered by e2e assertions for both the linked and unlinked cases. Signed-off-by: Émile Ré --- .../TrackerPatternPropertiesSection.tsx | 11 ++ .../_components/TrackerPatternRow.tsx | 11 +- e2e/console/tracker_pattern_test.go | 112 ++++++++++++++++-- packages/n8n-node/CHANGELOG.md | 4 + .../actions/trackerPattern/get.operation.ts | 1 + .../trackerPattern/getAll.operation.ts | 1 + pkg/cmd/tracker-pattern/list/list.go | 27 +++-- pkg/cmd/tracker-pattern/view/view.go | 34 ++++-- .../console/v1/graphql/cookie_banner.graphql | 10 ++ .../api/console/v1/types/tracker_pattern.go | 14 ++- pkg/server/api/mcp/v1/specification.yaml | 5 + .../api/mcp/v1/types/tracker_pattern.go | 31 ++--- 12 files changed, 208 insertions(+), 53 deletions(-) diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx index 60740dd0b..4c2a4e818 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx @@ -30,6 +30,7 @@ const trackerPatternPropertiesSectionFragment = graphql` excluded detectedCount lastMatchedAt + commonTrackerPatternId cookieCategory { name } @@ -105,6 +106,16 @@ export function TrackerPatternPropertiesSection({ {pattern.description} )} + + {pattern.commonTrackerPatternId + ? ( +
+ {__("Common catalog")} + {pattern.commonTrackerPatternId} +
+ ) + : {__("Manual")}} +
{pattern.excluded ? __("Yes") : __("No")} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx index 371034c13..be1d24629 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx @@ -48,6 +48,7 @@ const trackerPatternFragment = graphql` maxAgeSeconds excluded lastMatchedAt + commonTrackerPatternId cookieCategory { id name @@ -287,6 +288,7 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo const typeBadge = getTrackerTypeBadge(pattern.trackerType, __); const srcBadge = pattern.source ? getTrackerSourceBadge(pattern.source, __) : null; + const commonTrackerPatternId = pattern.commonTrackerPatternId; return ( @@ -294,8 +296,15 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo {typeBadge.label} -
+
{pattern.displayName} + {commonTrackerPatternId + ? ( + + {commonTrackerPatternId} + + ) + : {__("Manual")}} {pattern.description && ( {pattern.description} diff --git a/e2e/console/tracker_pattern_test.go b/e2e/console/tracker_pattern_test.go index be65a8322..f053639e4 100644 --- a/e2e/console/tracker_pattern_test.go +++ b/e2e/console/tracker_pattern_test.go @@ -15,12 +15,16 @@ package console_test import ( + "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.probo.inc/probo/e2e/internal/factory" "go.probo.inc/probo/e2e/internal/testutil" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" ) func TestTrackerPattern_Create(t *testing.T) { @@ -45,6 +49,7 @@ func TestTrackerPattern_Create(t *testing.T) { displayName maxAgeSeconds description + commonTrackerPatternId createdAt updatedAt } @@ -60,15 +65,16 @@ func TestTrackerPattern_Create(t *testing.T) { CreateTrackerPattern struct { TrackerPatternEdge struct { Node struct { - ID string `json:"id"` - Pattern string `json:"pattern"` - MatchType string `json:"matchType"` - TrackerType string `json:"trackerType"` - DisplayName string `json:"displayName"` - MaxAgeSeconds *int `json:"maxAgeSeconds"` - Description string `json:"description"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Pattern string `json:"pattern"` + MatchType string `json:"matchType"` + TrackerType string `json:"trackerType"` + DisplayName string `json:"displayName"` + MaxAgeSeconds *int `json:"maxAgeSeconds"` + Description string `json:"description"` + CommonTrackerPatternID *string `json:"commonTrackerPatternId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } `json:"node"` } `json:"trackerPatternEdge"` CookieBanner struct { @@ -101,6 +107,7 @@ func TestTrackerPattern_Create(t *testing.T) { require.NotNil(t, node.MaxAgeSeconds) assert.Equal(t, maxAge, *node.MaxAgeSeconds) assert.Equal(t, "Google Analytics tracking cookie", node.Description) + assert.Nil(t, node.CommonTrackerPatternID, "a manually created pattern is not linked to the common catalog") assert.Equal(t, bannerID, result.CreateTrackerPattern.CookieBanner.ID) }) @@ -607,6 +614,93 @@ func TestTrackerPattern_List(t *testing.T) { }) } +func TestTrackerPattern_CommonTrackerPatternID(t *testing.T) { + t.Parallel() + + t.Run("reflects the common catalog link", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID) + patternID := factory.CreateTrackerPattern(owner, categoryID) + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on TrackerPattern { + id + commonTrackerPatternId + } + } + } + ` + + var result struct { + Node struct { + ID string `json:"id"` + CommonTrackerPatternID *string `json:"commonTrackerPatternId"` + } `json:"node"` + } + + require.NoError(t, owner.Execute(query, map[string]any{"id": patternID}, &result)) + assert.Nil(t, result.Node.CommonTrackerPatternID, "a freshly created pattern has no catalog link") + + commonID := seedCommonTrackerPattern(t) + linkTrackerPatternToCommon(t, patternID, commonID) + + require.NoError(t, owner.Execute(query, map[string]any{"id": patternID}, &result)) + require.NotNil(t, result.Node.CommonTrackerPatternID, "the catalog link must surface once set") + assert.Equal(t, commonID.String(), *result.Node.CommonTrackerPatternID) + }) +} + +func seedCommonTrackerPattern(t *testing.T) gid.GID { + t.Helper() + + ctx := context.Background() + conn := dialTestPg(t, ctx) + t.Cleanup(func() { _ = conn.Close(ctx) }) + + id := gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType) + now := time.Now().UTC() + + _, err := conn.Exec(ctx, ` + INSERT INTO common_tracker_patterns ( + id, tracker_type, pattern, match_type, description, confidence, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 + ) + `, id, "COOKIE", "e2e_common_"+id.String(), "EXACT", "Seeded catalog description", 1.0, now, now) + require.NoError(t, err) + + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cleanupConn := dialTestPg(t, cleanupCtx) + defer func() { _ = cleanupConn.Close(cleanupCtx) }() + + _, err := cleanupConn.Exec(cleanupCtx, `DELETE FROM common_tracker_patterns WHERE id = $1`, id) + assert.NoError(t, err, "cleanup: cannot delete seeded common tracker pattern %s", id) + }) + + return id +} + +func linkTrackerPatternToCommon(t *testing.T, patternID string, commonID gid.GID) { + t.Helper() + + ctx := context.Background() + conn := dialTestPg(t, ctx) + t.Cleanup(func() { _ = conn.Close(ctx) }) + + _, err := conn.Exec(ctx, ` + UPDATE tracker_patterns SET common_tracker_pattern_id = $1 WHERE id = $2 + `, commonID, patternID) + require.NoError(t, err) +} + func TestTrackerPattern_RBAC(t *testing.T) { t.Parallel() diff --git a/packages/n8n-node/CHANGELOG.md b/packages/n8n-node/CHANGELOG.md index 12632b4a2..034bd84da 100644 --- a/packages/n8n-node/CHANGELOG.md +++ b/packages/n8n-node/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the `@probo/n8n-nodes-probo` package will be documented i ## Unreleased +### Added + +- Expose `commonTrackerPatternId` on tracker pattern `get`/`getAll` operations to indicate whether a pattern is linked to the common tracker catalog + ## [0.191.0] - 2026-06-02 ### Added diff --git a/packages/n8n-node/nodes/Probo/actions/trackerPattern/get.operation.ts b/packages/n8n-node/nodes/Probo/actions/trackerPattern/get.operation.ts index 7141481fa..cc533a729 100644 --- a/packages/n8n-node/nodes/Probo/actions/trackerPattern/get.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/trackerPattern/get.operation.ts @@ -51,6 +51,7 @@ export async function execute( source excluded lastMatchedAt + commonTrackerPatternId createdAt updatedAt } diff --git a/packages/n8n-node/nodes/Probo/actions/trackerPattern/getAll.operation.ts b/packages/n8n-node/nodes/Probo/actions/trackerPattern/getAll.operation.ts index 01c2680e0..e07149f94 100644 --- a/packages/n8n-node/nodes/Probo/actions/trackerPattern/getAll.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/trackerPattern/getAll.operation.ts @@ -86,6 +86,7 @@ export async function execute( source excluded lastMatchedAt + commonTrackerPatternId createdAt updatedAt } diff --git a/pkg/cmd/tracker-pattern/list/list.go b/pkg/cmd/tracker-pattern/list/list.go index c2eb1e562..c4b34e126 100644 --- a/pkg/cmd/tracker-pattern/list/list.go +++ b/pkg/cmd/tracker-pattern/list/list.go @@ -40,6 +40,7 @@ query($id: ID!, $first: Int, $after: CursorKey) { source excluded lastMatchedAt + commonTrackerPatternId } } pageInfo { @@ -53,14 +54,15 @@ query($id: ID!, $first: Int, $after: CursorKey) { ` type trackerPattern struct { - ID string `json:"id"` - Pattern string `json:"pattern"` - MatchType string `json:"matchType"` - TrackerType string `json:"trackerType"` - DisplayName string `json:"displayName"` - Source *string `json:"source"` - Excluded bool `json:"excluded"` - LastMatchedAt *string `json:"lastMatchedAt"` + ID string `json:"id"` + Pattern string `json:"pattern"` + MatchType string `json:"matchType"` + TrackerType string `json:"trackerType"` + DisplayName string `json:"displayName"` + Source *string `json:"source"` + Excluded bool `json:"excluded"` + LastMatchedAt *string `json:"lastMatchedAt"` + CommonTrackerPatternID *string `json:"commonTrackerPatternId"` } func NewCmdList(f *cmdutil.Factory) *cobra.Command { @@ -157,10 +159,15 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { lastMatched = cmdutil.FormatTime(*p.LastMatchedAt) } - rows = append(rows, []string{p.ID, p.Pattern, p.MatchType, p.TrackerType, p.DisplayName, source, excluded, lastMatched}) + commonPatternID := "" + if p.CommonTrackerPatternID != nil { + commonPatternID = *p.CommonTrackerPatternID + } + + rows = append(rows, []string{p.ID, p.Pattern, p.MatchType, p.TrackerType, p.DisplayName, source, excluded, lastMatched, commonPatternID}) } - t := cmdutil.NewTable("ID", "PATTERN", "MATCH TYPE", "TRACKER TYPE", "DISPLAY NAME", "SOURCE", "EXCLUDED", "LAST MATCHED").Rows(rows...) + t := cmdutil.NewTable("ID", "PATTERN", "MATCH TYPE", "TRACKER TYPE", "DISPLAY NAME", "SOURCE", "EXCLUDED", "LAST MATCHED", "COMMON PATTERN ID").Rows(rows...) _, _ = fmt.Fprintln(f.IOStreams.Out, t) if totalCount > len(patterns) { diff --git a/pkg/cmd/tracker-pattern/view/view.go b/pkg/cmd/tracker-pattern/view/view.go index f43e089ff..91309bf5f 100644 --- a/pkg/cmd/tracker-pattern/view/view.go +++ b/pkg/cmd/tracker-pattern/view/view.go @@ -39,6 +39,7 @@ query($id: ID!) { source excluded lastMatchedAt + commonTrackerPatternId createdAt updatedAt } @@ -48,19 +49,20 @@ query($id: ID!) { type viewResponse struct { Node *struct { - Typename string `json:"__typename"` - ID string `json:"id"` - Pattern string `json:"pattern"` - MatchType string `json:"matchType"` - TrackerType string `json:"trackerType"` - DisplayName string `json:"displayName"` - MaxAgeSeconds *int `json:"maxAgeSeconds"` - Description *string `json:"description"` - Source string `json:"source"` - Excluded bool `json:"excluded"` - LastMatchedAt *string `json:"lastMatchedAt"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Typename string `json:"__typename"` + ID string `json:"id"` + Pattern string `json:"pattern"` + MatchType string `json:"matchType"` + TrackerType string `json:"trackerType"` + DisplayName string `json:"displayName"` + MaxAgeSeconds *int `json:"maxAgeSeconds"` + Description *string `json:"description"` + Source string `json:"source"` + Excluded bool `json:"excluded"` + LastMatchedAt *string `json:"lastMatchedAt"` + CommonTrackerPatternID *string `json:"commonTrackerPatternId"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } `json:"node"` } @@ -138,6 +140,12 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Last Matched:"), cmdutil.FormatTime(*v.LastMatchedAt)) } + if v.CommonTrackerPatternID != nil && *v.CommonTrackerPatternID != "" { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Common Pattern:"), *v.CommonTrackerPatternID) + } else { + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Origin:"), "Manual (no catalog link)") + } + _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(v.CreatedAt)) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(v.UpdatedAt)) diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 1bc17728d..2bca80a12 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -313,6 +313,16 @@ type TrackerPattern implements Node """ commonThirdParty: CommonThirdParty @goField(forceResolver: true) + """ + The common tracker-pattern catalog entry this pattern is linked to, + if any. Non-null means the pattern was matched to the global catalog + (so its description may originate from the Open Cookie Database seed + or the mapping/enrichment agents); null means no catalog link + (description was added manually or inherited). Exposed primarily for + debugging agent-generated descriptions. + """ + commonTrackerPatternId: ID + detectedTrackers( first: Int after: CursorKey diff --git a/pkg/server/api/console/v1/types/tracker_pattern.go b/pkg/server/api/console/v1/types/tracker_pattern.go index 6d746d65d..e816ec3cc 100644 --- a/pkg/server/api/console/v1/types/tracker_pattern.go +++ b/pkg/server/api/console/v1/types/tracker_pattern.go @@ -31,10 +31,14 @@ type ( // (cookieCategory, detectedTrackers, thirdParty, commonThirdParty, // detectedCount, permission) are populated by the resolver. // - // ThirdPartyID and CommonTrackerPatternID are not exposed in - // GraphQL — they are foreign-key handles the resolver uses to load - // the linked third party (org-scoped or via the common catalog) - // without re-querying coredata. + // ThirdPartyID is not exposed in GraphQL — it is a foreign-key + // handle the resolver uses to load the linked org-scoped third + // party without re-querying coredata. + // + // CommonTrackerPatternID is exposed directly as the + // commonTrackerPatternId field: a non-null value indicates the + // pattern is linked to the common tracker-pattern catalog, which is + // used to debug the provenance of agent-generated descriptions. TrackerPattern struct { ID gid.GID `json:"id"` TrackerType coredata.TrackerType `json:"trackerType"` @@ -55,7 +59,7 @@ type ( Permission bool `json:"permission"` ThirdPartyID *gid.GID `json:"-"` - CommonTrackerPatternID *gid.GID `json:"-"` + CommonTrackerPatternID *gid.GID `json:"commonTrackerPatternId,omitempty"` } TrackerPatternConnection struct { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index c5867c0ce..3abc9491b 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -9560,6 +9560,11 @@ components: - "null" format: date-time description: Timestamp when a cookie last matched this pattern + common_tracker_pattern_id: + anyOf: + - $ref: "#/components/schemas/GID" + - type: "null" + description: Linked common tracker-pattern catalog ID, if any. Non-null indicates the pattern was matched to the global catalog (description may originate from the seed or agents); null means no catalog link. created_at: type: string format: date-time diff --git a/pkg/server/api/mcp/v1/types/tracker_pattern.go b/pkg/server/api/mcp/v1/types/tracker_pattern.go index b5786d555..b9960aae8 100644 --- a/pkg/server/api/mcp/v1/types/tracker_pattern.go +++ b/pkg/server/api/mcp/v1/types/tracker_pattern.go @@ -26,21 +26,22 @@ func NewTrackerPattern(p *coredata.TrackerPattern) *TrackerPattern { } return &TrackerPattern{ - ID: p.ID, - OrganizationID: p.OrganizationID, - CookieBannerID: p.CookieBannerID, - CookieCategoryID: p.CookieCategoryID, - TrackerType: TrackerPatternTrackerType(p.TrackerType), - Pattern: p.Pattern, - MatchType: TrackerPatternMatchType(p.MatchType), - DisplayName: p.DisplayName, - MaxAgeSeconds: p.MaxAgeSeconds, - Description: p.Description, - Source: source, - Excluded: p.Excluded, - LastMatchedAt: p.LastMatchedAt, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, + ID: p.ID, + OrganizationID: p.OrganizationID, + CookieBannerID: p.CookieBannerID, + CookieCategoryID: p.CookieCategoryID, + TrackerType: TrackerPatternTrackerType(p.TrackerType), + Pattern: p.Pattern, + MatchType: TrackerPatternMatchType(p.MatchType), + DisplayName: p.DisplayName, + MaxAgeSeconds: p.MaxAgeSeconds, + Description: p.Description, + Source: source, + Excluded: p.Excluded, + LastMatchedAt: p.LastMatchedAt, + CommonTrackerPatternID: p.CommonTrackerPatternID, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, } }