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