From 21f92352d5eee67cbc0d458b81dccac63de5a461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 5 May 2026 16:21:18 +0400 Subject: [PATCH] Migrate detection page to TrackerPattern GraphQL types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TrackerPattern type, connection, and filter to GraphQL schema - Add uncategorisedTrackerPatterns field on CookieBanner - Add updateTrackerPattern, deleteTrackerPattern, moveTrackerPatternToCategory mutations - Implement all resolvers backed by tracker_patterns table - Add TrackerPattern service methods (CRUD + list/count) - Add LoadUncategorisedByCookieBannerID on TrackerPatterns coredata - Update detection page to use TrackerPattern fragment, queries, and mutations - CookieCategory resolver uses dataloader (not just struct ID) Signed-off-by: Émile Ré --- .../detection/CookieBannerDetectionPage.tsx | 10 +- .../_components/DetectionPatternRow.tsx | 32 +- pkg/cookiebanner/service.go | 291 ++++++++++++++++++ pkg/coredata/tracker_pattern.go | 134 ++++++++ .../api/console/v1/cookie_banner_resolvers.go | 222 +++++++++++++ .../console/v1/graphql/cookie_banner.graphql | 100 ++++++ .../api/console/v1/types/tracker_pattern.go | 102 ++++++ 7 files changed, 870 insertions(+), 21 deletions(-) create mode 100644 pkg/server/api/console/v1/types/tracker_pattern.go diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx index 7d3dfbffc..1e075c80e 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx @@ -58,14 +58,14 @@ const detectionFragment = graphql` @refetchable(queryName: "CookieBannerDetectionPageRefetchQuery") @argumentDefinitions( first: { type: "Int", defaultValue: 50 } - order: { type: "CookiePatternOrder", defaultValue: { field: NAME, direction: ASC } } + order: { type: "TrackerPatternOrder", defaultValue: { field: NAME, direction: ASC } } after: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null } last: { type: "Int", defaultValue: null } query: { type: "String", defaultValue: null } source: { type: "CookieSource", defaultValue: null } ) { - uncategorisedPatterns( + uncategorisedTrackerPatterns( first: $first after: $after last: $last @@ -74,7 +74,7 @@ const detectionFragment = graphql` filter: { query: $query, source: $source } ) @connection( - key: "CookieBannerDetectionPage_uncategorisedPatterns" + key: "CookieBannerDetectionPage_uncategorisedTrackerPatterns" filters: ["filter", "orderBy"] ) @required(action: THROW) { @@ -112,8 +112,8 @@ export default function CookieBannerDetectionPage({ CookieBannerDetectionPageFragment$key >(detectionFragment, data.node); - const connectionId = fragmentData.uncategorisedPatterns.__id; - const patterns = fragmentData.uncategorisedPatterns.edges.map(edge => edge.node) ?? []; + const connectionId = fragmentData.uncategorisedTrackerPatterns.__id; + const patterns = fragmentData.uncategorisedTrackerPatterns.edges.map(edge => edge.node) ?? []; const refetchFilters = (overrides: Record = {}) => { startTransition(() => { diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx index 2a6310c05..0925c7686 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx @@ -44,7 +44,7 @@ import { } from "./MoveToCategoryDropdown"; const detectionPatternFragment = graphql` - fragment DetectionPatternRowFragment on CookiePattern { + fragment DetectionPatternRowFragment on TrackerPattern { id trackerType displayName @@ -59,11 +59,11 @@ const detectionPatternFragment = graphql` const deletePatternMutation = graphql` mutation DetectionPatternRowDeleteMutation( - $input: DeleteCookiePatternInput! + $input: DeleteTrackerPatternInput! $connections: [ID!]! ) { - deleteCookiePattern(input: $input) { - deletedCookiePatternId @deleteEdge(connections: $connections) + deleteTrackerPattern(input: $input) { + deletedTrackerPatternId @deleteEdge(connections: $connections) cookieBanner { id latestVersion { @@ -78,10 +78,10 @@ const deletePatternMutation = graphql` const movePatternMutation = graphql` mutation DetectionPatternRowMoveMutation( - $input: MoveCookiePatternToCategoryInput! + $input: MoveTrackerPatternToCategoryInput! ) { - moveCookiePatternToCategory(input: $input) { - cookiePattern { + moveTrackerPatternToCategory(input: $input) { + trackerPattern { id cookieCategory { id @@ -101,10 +101,10 @@ const movePatternMutation = graphql` const updatePatternMutation = graphql` mutation DetectionPatternRowUpdateMutation( - $input: UpdateCookiePatternInput! + $input: UpdateTrackerPatternInput! ) { - updateCookiePattern(input: $input) { - cookiePattern { + updateTrackerPattern(input: $input) { + trackerPattern { id displayName maxAgeSeconds @@ -174,7 +174,7 @@ export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatte new Promise((resolve) => { deletePattern({ variables: { - input: { cookiePatternId: pattern.id }, + input: { trackerPatternId: pattern.id }, connections: [connectionId], }, onCompleted(_, errors) { @@ -203,13 +203,13 @@ export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatte movePattern({ variables: { input: { - cookiePatternId: pattern.id, + trackerPatternId: pattern.id, targetCookieCategoryId: targetCategoryId, }, }, updater(store) { - const payload = store.getRootField("moveCookiePatternToCategory"); - if (!payload?.getLinkedRecord("cookiePattern")) { + const payload = store.getRootField("moveTrackerPatternToCategory"); + if (!payload?.getLinkedRecord("trackerPattern")) { return; } @@ -235,7 +235,7 @@ export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatte updatePattern({ variables: { input: { - cookiePatternId: pattern.id, + trackerPatternId: pattern.id, excluded: !pattern.excluded, }, }, @@ -255,7 +255,7 @@ export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatte updatePattern({ variables: { input: { - cookiePatternId: pattern.id, + trackerPatternId: pattern.id, displayName: data.displayName, description: data.description, maxAgeSeconds: data.maxAgeSeconds, diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index a31f1729c..d58014e12 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -159,6 +159,24 @@ type ( Resources []DetectedResourceItem } + UpdateTrackerPatternRequest struct { + TrackerPatternID gid.GID + DisplayName *string + MaxAgeSeconds **int + Description *string + Excluded *bool + } + + MoveTrackerPatternToCategoryRequest struct { + TrackerPatternID gid.GID + TargetCookieCategoryID gid.GID + } + + MoveTrackerPatternToCategoryResult struct { + TrackerPattern *coredata.TrackerPattern + Banner *coredata.CookieBanner + } + BannerConfig struct { BannerID gid.GID `json:"banner_id"` Version int `json:"version"` @@ -2539,3 +2557,276 @@ func (s *Service) reportDetectedTracker( return nil } + +func (s *Service) GetTrackerPattern( + ctx context.Context, + scope coredata.Scoper, + trackerPatternID gid.GID, +) (*coredata.TrackerPattern, error) { + var pattern coredata.TrackerPattern + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := pattern.LoadByID(ctx, conn, scope, trackerPatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load tracker pattern: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &pattern, nil +} + +func (s *Service) UpdateTrackerPattern( + ctx context.Context, + scope coredata.Scoper, + req UpdateTrackerPatternRequest, +) (*coredata.TrackerPattern, error) { + var pattern coredata.TrackerPattern + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := pattern.LoadByID(ctx, tx, scope, req.TrackerPatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load tracker pattern: %w", err) + } + + displayNameChanged := req.DisplayName != nil && *req.DisplayName != pattern.DisplayName + maxAgeChanged := req.MaxAgeSeconds != nil && !ptrEqual(*req.MaxAgeSeconds, pattern.MaxAgeSeconds) + descChanged := req.Description != nil && *req.Description != pattern.Description + excludedChanged := req.Excluded != nil && *req.Excluded != pattern.Excluded + + if !displayNameChanged && !maxAgeChanged && !descChanged && !excludedChanged { + return nil + } + + staysExcluded := pattern.Excluded && (req.Excluded == nil || *req.Excluded) + + if req.DisplayName != nil { + pattern.DisplayName = *req.DisplayName + } + if req.MaxAgeSeconds != nil { + pattern.MaxAgeSeconds = *req.MaxAgeSeconds + } + if req.Description != nil { + pattern.Description = *req.Description + } + if req.Excluded != nil { + pattern.Excluded = *req.Excluded + } + + pattern.UpdatedAt = time.Now() + + if err := pattern.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update tracker pattern: %w", err) + } + + if !staysExcluded && pattern.TrackerType == coredata.TrackerTypeCookie { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &pattern, nil +} + +func (s *Service) DeleteTrackerPattern( + ctx context.Context, + scope coredata.Scoper, + trackerPatternID gid.GID, +) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var pattern coredata.TrackerPattern + if err := pattern.LoadByID(ctx, tx, scope, trackerPatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load tracker pattern: %w", err) + } + + wasExcluded := pattern.Excluded + + if err := pattern.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot delete tracker pattern: %w", err) + } + + if !wasExcluded && pattern.TrackerType == coredata.TrackerTypeCookie { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + } + + return nil + }, + ) +} + +func (s *Service) MoveTrackerPatternToCategory( + ctx context.Context, + scope coredata.Scoper, + req MoveTrackerPatternToCategoryRequest, +) (*MoveTrackerPatternToCategoryResult, error) { + var result MoveTrackerPatternToCategoryResult + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var pattern coredata.TrackerPattern + if err := pattern.LoadByID(ctx, tx, scope, req.TrackerPatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load tracker pattern: %w", err) + } + + var target coredata.CookieCategory + if err := target.LoadByID(ctx, tx, scope, req.TargetCookieCategoryID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCategoryNotFound + } + return fmt.Errorf("cannot load target cookie category: %w", err) + } + + if pattern.CookieCategoryID == target.ID { + return ErrSamePatternCategoryMove + } + + if pattern.CookieBannerID != target.CookieBannerID { + return ErrCategoriesBannerMismatch + } + + wasExcluded := pattern.Excluded + + pattern.CookieCategoryID = target.ID + pattern.UpdatedAt = time.Now() + + if err := pattern.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update tracker pattern: %w", err) + } + + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + if !wasExcluded && pattern.TrackerType == coredata.TrackerTypeCookie { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + } + + result.TrackerPattern = &pattern + result.Banner = &banner + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (s *Service) ListUncategorisedTrackerPatterns( + ctx context.Context, + scope coredata.Scoper, + bannerID gid.GID, + cursor *page.Cursor[coredata.CookiePatternOrderField], + filter *coredata.CookiePatternFilter, +) (coredata.TrackerPatterns, error) { + var patterns coredata.TrackerPatterns + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := patterns.LoadUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil { + return fmt.Errorf("cannot list uncategorised tracker patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return patterns, nil +} + +func (s *Service) CountUncategorisedTrackerPatterns( + ctx context.Context, + scope coredata.Scoper, + bannerID gid.GID, + filter *coredata.CookiePatternFilter, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var patterns coredata.TrackerPatterns + var err error + + count, err = patterns.CountUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, filter) + if err != nil { + return fmt.Errorf("cannot count uncategorised tracker patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Service) CountDetectedTrackersByPatternID( + ctx context.Context, + scope coredata.Scoper, + trackerPatternID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var trackers coredata.DetectedTrackers + var err error + + count, err = trackers.CountByTrackerPatternID(ctx, conn, scope, trackerPatternID) + if err != nil { + return fmt.Errorf("cannot count detected trackers: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index 628f73378..7bf86c395 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" ) type ( @@ -49,6 +50,29 @@ type ( TrackerPatterns []*TrackerPattern ) +func (tp *TrackerPattern) CursorKey(field CookiePatternOrderField) page.CursorKey { + switch field { + case CookiePatternOrderFieldCreatedAt: + return page.NewCursorKey(tp.ID, tp.CreatedAt) + case CookiePatternOrderFieldName: + return page.NewCursorKey(tp.ID, tp.DisplayName) + case CookiePatternOrderFieldLastMatchedAt: + if tp.LastMatchedAt == nil { + return page.NewCursorKey(tp.ID, time.Time{}) + } + return page.NewCursorKey(tp.ID, *tp.LastMatchedAt) + case CookiePatternOrderFieldUpdatedAt: + return page.NewCursorKey(tp.ID, tp.UpdatedAt) + case CookiePatternOrderFieldSource: + if tp.Source == nil { + return page.NewCursorKey(tp.ID, "") + } + return page.NewCursorKey(tp.ID, string(*tp.Source)) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + func (tp *TrackerPattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { q := `SELECT organization_id FROM tracker_patterns WHERE id = $1 LIMIT 1;` @@ -575,3 +599,113 @@ WHERE return nil } + +func (tps *TrackerPatterns) LoadUncategorisedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cursor *page.Cursor[CookiePatternOrderField], + filter *CookiePatternFilter, +) error { + q := ` +SELECT + id, + organization_id, + cookie_banner_id, + cookie_category_id, + tracker_type, + pattern, + match_type, + display_name, + description, + excluded, + max_age_seconds, + source, + last_matched_at, + created_at, + updated_at +FROM + tracker_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND cookie_category_id = ( + SELECT id FROM cookie_categories + WHERE cookie_banner_id = @cookie_banner_id + AND kind = @category_kind + AND %s + LIMIT 1 + ) + AND %s + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "category_kind": CookieCategoryKindUncategorised, + } + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query uncategorised tracker patterns: %w", err) + } + + patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerPattern]) + if err != nil { + return fmt.Errorf("cannot collect uncategorised tracker patterns: %w", err) + } + + *tps = patterns + + return nil +} + +func (tps *TrackerPatterns) CountUncategorisedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *CookiePatternFilter, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + tracker_patterns +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND cookie_category_id = ( + SELECT id FROM cookie_categories + WHERE cookie_banner_id = @cookie_banner_id + AND kind = @category_kind + AND %s + LIMIT 1 + ) + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "category_kind": CookieCategoryKindUncategorised, + } + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, filter.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index f45ceb4ef..10a5dce46 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -213,6 +213,42 @@ func (r *cookieBannerResolver) UncategorisedPatterns(ctx context.Context, obj *t return types.NewCookiePatternConnectionWithFilter(p, r, obj.ID, coredataFilter), nil } +// UncategorisedTrackerPatterns is the resolver for the uncategorisedTrackerPatterns field. +func (r *cookieBannerResolver) UncategorisedTrackerPatterns(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.TrackerPatternOrderBy, filter *types.TrackerPatternFilter) (*types.TrackerPatternConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionCookiePatternList); err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.CookiePatternOrderField]{ + Field: coredata.CookiePatternOrderFieldName, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.CookiePatternOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + scope := coredata.NewScopeFromObjectID(obj.ID) + + coredataFilter := coredata.NewCookiePatternFilter(nil, nil, nil) + if filter != nil { + coredataFilter = coredataFilter.WithQuery(filter.Query).WithSource(filter.Source) + } + + patterns, err := r.cookieBanner.ListUncategorisedTrackerPatterns(ctx, scope, obj.ID, cursor, coredataFilter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list uncategorised tracker patterns", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + p := page.NewPage(patterns, cursor) + + return types.NewTrackerPatternConnectionWithFilter(p, r, obj.ID, filter), nil +} + // Permission is the resolver for the permission field. func (r *cookieBannerResolver) Permission(ctx context.Context, obj *types.CookieBanner, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -997,6 +1033,182 @@ func (r *mutationResolver) UpsertCookieBannerTranslation(ctx context.Context, in }, nil } +// UpdateTrackerPattern is the resolver for the updateTrackerPattern field. +func (r *mutationResolver) UpdateTrackerPattern(ctx context.Context, input types.UpdateTrackerPatternInput) (*types.UpdateTrackerPatternPayload, error) { + if err := r.authorize(ctx, input.TrackerPatternID, probo.ActionCookiePatternUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerPatternID) + + pattern, err := r.cookieBanner.UpdateTrackerPattern( + ctx, + scope, + cookiebanner.UpdateTrackerPatternRequest{ + TrackerPatternID: input.TrackerPatternID, + DisplayName: input.DisplayName, + MaxAgeSeconds: gqlutils.UnwrapOmittable(input.MaxAgeSeconds), + Description: input.Description, + Excluded: input.Excluded, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrCookiePatternNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot update tracker pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(pattern.CookieBannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, pattern.CookieBannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateTrackerPatternPayload{ + TrackerPattern: types.NewTrackerPatternNode(pattern), + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// DeleteTrackerPattern is the resolver for the deleteTrackerPattern field. +func (r *mutationResolver) DeleteTrackerPattern(ctx context.Context, input types.DeleteTrackerPatternInput) (*types.DeleteTrackerPatternPayload, error) { + if err := r.authorize(ctx, input.TrackerPatternID, probo.ActionCookiePatternDelete); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerPatternID) + + pattern, err := r.cookieBanner.GetTrackerPattern(ctx, scope, input.TrackerPatternID) + if err != nil { + if errors.Is(err, cookiebanner.ErrCookiePatternNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot get tracker pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerID := pattern.CookieBannerID + + if err := r.cookieBanner.DeleteTrackerPattern(ctx, scope, input.TrackerPatternID); err != nil { + if errors.Is(err, cookiebanner.ErrCookiePatternNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete tracker pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(bannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, bannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteTrackerPatternPayload{ + DeletedTrackerPatternID: input.TrackerPatternID, + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// MoveTrackerPatternToCategory is the resolver for the moveTrackerPatternToCategory field. +func (r *mutationResolver) MoveTrackerPatternToCategory(ctx context.Context, input types.MoveTrackerPatternToCategoryInput) (*types.MoveTrackerPatternToCategoryPayload, error) { + if err := r.authorize(ctx, input.TrackerPatternID, probo.ActionCookiePatternUpdate); err != nil { + return nil, err + } + + if err := r.authorize(ctx, input.TargetCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.TrackerPatternID) + + result, err := r.cookieBanner.MoveTrackerPatternToCategory( + ctx, + scope, + cookiebanner.MoveTrackerPatternToCategoryRequest{ + TrackerPatternID: input.TrackerPatternID, + TargetCookieCategoryID: input.TargetCookieCategoryID, + }, + ) + if err != nil { + switch { + case errors.Is(err, cookiebanner.ErrCategoryNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrCookiePatternNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch): + return nil, gqlutils.NotFoundf(ctx, "tracker pattern or target category not found") + default: + r.logger.ErrorCtx(ctx, "cannot move tracker pattern to category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + } + + return &types.MoveTrackerPatternToCategoryPayload{ + TrackerPattern: types.NewTrackerPatternNode(result.TrackerPattern), + CookieBanner: types.NewCookieBanner(result.Banner), + }, nil +} + +// CookieCategory is the resolver for the cookieCategory field. +func (r *trackerPatternResolver) CookieCategory(ctx context.Context, obj *types.TrackerPattern) (*types.CookieCategory, error) { + if err := r.authorize(ctx, obj.CookieCategory.ID, probo.ActionCookieCategoryGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + category, err := loaders.CookieCategory.Load(ctx, obj.CookieCategory.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot get cookie category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCookieCategory(category), nil +} + +// DetectedCount is the resolver for the detectedCount field. +func (r *trackerPatternResolver) DetectedCount(ctx context.Context, obj *types.TrackerPattern) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ID) + + count, err := r.cookieBanner.CountDetectedTrackersByPatternID(ctx, scope, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count detected trackers", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + +// Permission is the resolver for the permission field. +func (r *trackerPatternResolver) Permission(ctx context.Context, obj *types.TrackerPattern, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *trackerPatternConnectionResolver) TotalCount(ctx context.Context, obj *types.TrackerPatternConnection) (int, error) { + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + filter := coredata.NewCookiePatternFilter(nil, nil, nil) + if obj.Filter != nil { + filter = filter.WithQuery(obj.Filter.Query).WithSource(obj.Filter.Source) + } + + count, err := r.cookieBanner.CountUncategorisedTrackerPatterns(ctx, scope, obj.ParentID, filter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count uncategorised tracker patterns", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + // CookieBanner returns schema.CookieBannerResolver implementation. func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} } @@ -1026,6 +1238,14 @@ func (r *Resolver) CookiePatternConnection() schema.CookiePatternConnectionResol return &cookiePatternConnectionResolver{r} } +// TrackerPattern returns schema.TrackerPatternResolver implementation. +func (r *Resolver) TrackerPattern() schema.TrackerPatternResolver { return &trackerPatternResolver{r} } + +// TrackerPatternConnection returns schema.TrackerPatternConnectionResolver implementation. +func (r *Resolver) TrackerPatternConnection() schema.TrackerPatternConnectionResolver { + return &trackerPatternConnectionResolver{r} +} + type cookieBannerResolver struct{ *Resolver } type cookieBannerConnectionResolver struct{ *Resolver } type cookieBannerVersionResolver struct{ *Resolver } @@ -1033,3 +1253,5 @@ type cookieCategoryResolver struct{ *Resolver } type cookieCategoryConnectionResolver struct{ *Resolver } type cookiePatternResolver struct{ *Resolver } type cookiePatternConnectionResolver struct{ *Resolver } +type trackerPatternResolver struct{ *Resolver } +type trackerPatternConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 34fc9e4da..adae3e563 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -160,6 +160,15 @@ type CookieBanner implements Node { filter: CookiePatternFilter ): CookiePatternConnection @goField(forceResolver: true) + uncategorisedTrackerPatterns( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: TrackerPatternOrder + filter: TrackerPatternFilter + ): TrackerPatternConnection @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -285,6 +294,56 @@ type CookiePatternEdge { node: CookiePattern! } +type TrackerPattern implements Node { + id: ID! + cookieCategory: CookieCategory @goField(forceResolver: true) + trackerType: TrackerType! + pattern: String! + matchType: CookiePatternMatchType! + displayName: String! + maxAgeSeconds: Int + description: String! + source: CookieSource + excluded: Boolean! + detectedCount: Int! @goField(forceResolver: true) + lastMatchedAt: Datetime + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type TrackerPatternConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerPatternConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [TrackerPatternEdge!]! + pageInfo: PageInfo! +} + +type TrackerPatternEdge { + cursor: CursorKey! + node: TrackerPattern! +} + +input TrackerPatternOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerPatternOrderBy" + ) { + direction: OrderDirection! + field: CookiePatternOrderField! +} + +input TrackerPatternFilter + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrackerPatternFilter" + ) { + query: String + source: CookieSource + trackerType: TrackerType +} + type CookieBannerVersion implements Node { id: ID! version: Int! @@ -382,6 +441,15 @@ extend type Mutation { upsertCookieBannerTranslation( input: UpsertCookieBannerTranslationInput! ): UpsertCookieBannerTranslationPayload! + updateTrackerPattern( + input: UpdateTrackerPatternInput! + ): UpdateTrackerPatternPayload! + deleteTrackerPattern( + input: DeleteTrackerPatternInput! + ): DeleteTrackerPatternPayload! + moveTrackerPatternToCategory( + input: MoveTrackerPatternToCategoryInput! + ): MoveTrackerPatternToCategoryPayload! } input CreateCookieBannerInput { @@ -547,3 +615,35 @@ type UpsertCookieBannerTranslationPayload { cookieBannerTranslation: CookieBannerTranslation! cookieBanner: CookieBanner! } + +input UpdateTrackerPatternInput { + trackerPatternId: ID! + displayName: String + maxAgeSeconds: Int @goField(omittable: true) + description: String + excluded: Boolean +} + +input DeleteTrackerPatternInput { + trackerPatternId: ID! +} + +input MoveTrackerPatternToCategoryInput { + trackerPatternId: ID! + targetCookieCategoryId: ID! +} + +type UpdateTrackerPatternPayload { + trackerPattern: TrackerPattern! + cookieBanner: CookieBanner! +} + +type DeleteTrackerPatternPayload { + deletedTrackerPatternId: ID! + cookieBanner: CookieBanner! +} + +type MoveTrackerPatternToCategoryPayload { + trackerPattern: TrackerPattern! + cookieBanner: CookieBanner! +} diff --git a/pkg/server/api/console/v1/types/tracker_pattern.go b/pkg/server/api/console/v1/types/tracker_pattern.go new file mode 100644 index 000000000..26d625662 --- /dev/null +++ b/pkg/server/api/console/v1/types/tracker_pattern.go @@ -0,0 +1,102 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + TrackerPatternOrderBy OrderBy[coredata.CookiePatternOrderField] + + TrackerPatternConnection struct { + TotalCount int + Edges []*TrackerPatternEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + Filter *TrackerPatternFilter + } + + TrackerPatternFilter struct { + Query *string + Source *coredata.CookieSource + TrackerType *coredata.TrackerType + } +) + +func NewTrackerPatternConnection( + p *page.Page[*coredata.TrackerPattern, coredata.CookiePatternOrderField], + parentType any, + parentID gid.GID, +) *TrackerPatternConnection { + edges := make([]*TrackerPatternEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewTrackerPatternEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &TrackerPatternConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, + } +} + +func NewTrackerPatternConnectionWithFilter( + p *page.Page[*coredata.TrackerPattern, coredata.CookiePatternOrderField], + parentType any, + parentID gid.GID, + filter *TrackerPatternFilter, +) *TrackerPatternConnection { + conn := NewTrackerPatternConnection(p, parentType, parentID) + conn.Filter = filter + return conn +} + +func NewTrackerPatternEdge(tp *coredata.TrackerPattern, orderBy coredata.CookiePatternOrderField) *TrackerPatternEdge { + return &TrackerPatternEdge{ + Cursor: tp.CursorKey(orderBy), + Node: NewTrackerPatternNode(tp), + } +} + +func NewTrackerPatternNode(tp *coredata.TrackerPattern) *TrackerPattern { + return &TrackerPattern{ + ID: tp.ID, + CookieCategory: &CookieCategory{ + ID: tp.CookieCategoryID, + CookieBanner: &CookieBanner{ + ID: tp.CookieBannerID, + }, + }, + TrackerType: tp.TrackerType, + Pattern: tp.Pattern, + MatchType: tp.MatchType, + DisplayName: tp.DisplayName, + MaxAgeSeconds: tp.MaxAgeSeconds, + Description: tp.Description, + Source: tp.Source, + Excluded: tp.Excluded, + LastMatchedAt: tp.LastMatchedAt, + CreatedAt: tp.CreatedAt, + UpdatedAt: tp.UpdatedAt, + } +}