diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 87a84c8be..3b17041ec 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -1434,6 +1434,61 @@ func (s *Service) CountCookiePatternsForCategory( return count, nil } +func (s *Service) ListUncategorisedCookiePatterns( + ctx context.Context, + scope coredata.Scoper, + bannerID gid.GID, + cursor *page.Cursor[coredata.CookiePatternOrderField], + filter *coredata.CookiePatternFilter, +) (coredata.CookiePatterns, error) { + var patterns coredata.CookiePatterns + + 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 cookie patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return patterns, nil +} + +func (s *Service) CountUncategorisedCookiePatterns( + 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.CookiePatterns + var err error + + count, err = patterns.CountUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, filter) + if err != nil { + return fmt.Errorf("cannot count uncategorised cookie patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + func (s *Service) CountCookiesForPattern( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/coredata/cookie_pattern.go b/pkg/coredata/cookie_pattern.go index 195896bc8..efce37607 100644 --- a/pkg/coredata/cookie_pattern.go +++ b/pkg/coredata/cookie_pattern.go @@ -53,6 +53,17 @@ func (cp *CookiePattern) CursorKey(field CookiePatternOrderField) page.CursorKey switch field { case CookiePatternOrderFieldCreatedAt: return page.NewCursorKey(cp.ID, cp.CreatedAt) + case CookiePatternOrderFieldName: + return page.NewCursorKey(cp.ID, cp.DisplayName) + case CookiePatternOrderFieldLastMatchedAt: + if cp.LastMatchedAt == nil { + return page.NewCursorKey(cp.ID, time.Time{}) + } + return page.NewCursorKey(cp.ID, *cp.LastMatchedAt) + case CookiePatternOrderFieldUpdatedAt: + return page.NewCursorKey(cp.ID, cp.UpdatedAt) + case CookiePatternOrderFieldSource: + return page.NewCursorKey(cp.ID, string(cp.Source)) } panic(fmt.Sprintf("unsupported order by: %s", field)) @@ -639,6 +650,115 @@ WHERE return nil } +func (cps *CookiePatterns) 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, + pattern, + match_type, + display_name, + max_age_seconds, + description, + source, + excluded, + last_matched_at, + created_at, + updated_at +FROM + cookie_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 cookie patterns: %w", err) + } + + patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern]) + if err != nil { + return fmt.Errorf("cannot collect uncategorised cookie patterns: %w", err) + } + + *cps = patterns + + return nil +} + +func (cps *CookiePatterns) CountUncategorisedByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + filter *CookiePatternFilter, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_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 +} + func (cps *CookiePatterns) MoveToCategoryByCookieCategoryID( ctx context.Context, tx pg.Tx, diff --git a/pkg/coredata/cookie_pattern_filter.go b/pkg/coredata/cookie_pattern_filter.go index da0cffeba..49fc77d26 100644 --- a/pkg/coredata/cookie_pattern_filter.go +++ b/pkg/coredata/cookie_pattern_filter.go @@ -23,6 +23,8 @@ type CookiePatternFilter struct { matchType *CookiePatternMatchType cookieCategoryID *gid.GID excluded *bool + query *string + source *CookieSource } func NewCookiePatternFilter( @@ -37,6 +39,16 @@ func NewCookiePatternFilter( } } +func (f *CookiePatternFilter) WithQuery(query *string) *CookiePatternFilter { + f.query = query + return f +} + +func (f *CookiePatternFilter) WithSource(source *CookieSource) *CookiePatternFilter { + f.source = source + return f +} + func (f *CookiePatternFilter) SQLFragment() string { if f == nil { return "TRUE" @@ -64,6 +76,20 @@ func (f *CookiePatternFilter) SQLFragment() string { excluded = @filter_excluded ELSE TRUE END + AND + CASE + WHEN @filter_query::text IS NOT NULL AND @filter_query::text != '' THEN + (display_name ILIKE '%' || @filter_query || '%' + OR description ILIKE '%' || @filter_query || '%') + ELSE TRUE + END + AND + CASE + WHEN @has_source_filter::boolean = false THEN TRUE + WHEN @has_source_filter::boolean = true THEN + source = @filter_source::cookie_source + ELSE TRUE + END )` } @@ -79,6 +105,9 @@ func (f *CookiePatternFilter) SQLArguments() pgx.StrictNamedArgs { "filter_cookie_category_id": nil, "has_excluded_filter": false, "filter_excluded": nil, + "filter_query": nil, + "has_source_filter": false, + "filter_source": nil, } if f.matchType != nil { @@ -96,5 +125,14 @@ func (f *CookiePatternFilter) SQLArguments() pgx.StrictNamedArgs { args["filter_excluded"] = *f.excluded } + if f.query != nil { + args["filter_query"] = *f.query + } + + if f.source != nil { + args["has_source_filter"] = true + args["filter_source"] = string(*f.source) + } + return args } diff --git a/pkg/coredata/cookie_pattern_order_field.go b/pkg/coredata/cookie_pattern_order_field.go index ed57c7390..2117aad26 100644 --- a/pkg/coredata/cookie_pattern_order_field.go +++ b/pkg/coredata/cookie_pattern_order_field.go @@ -19,20 +19,36 @@ import "fmt" type CookiePatternOrderField string const ( - CookiePatternOrderFieldCreatedAt CookiePatternOrderField = "CREATED_AT" + CookiePatternOrderFieldCreatedAt CookiePatternOrderField = "CREATED_AT" + CookiePatternOrderFieldName CookiePatternOrderField = "NAME" + CookiePatternOrderFieldLastMatchedAt CookiePatternOrderField = "LAST_MATCHED_AT" + CookiePatternOrderFieldUpdatedAt CookiePatternOrderField = "UPDATED_AT" + CookiePatternOrderFieldSource CookiePatternOrderField = "SOURCE" ) func (p CookiePatternOrderField) Column() string { switch p { case CookiePatternOrderFieldCreatedAt: return "created_at" + case CookiePatternOrderFieldName: + return "display_name" + case CookiePatternOrderFieldLastMatchedAt: + return "COALESCE(last_matched_at, '0001-01-01T00:00:00Z'::timestamptz)" + case CookiePatternOrderFieldUpdatedAt: + return "updated_at" + case CookiePatternOrderFieldSource: + return "source" } panic(fmt.Sprintf("unsupported order by: %s", p)) } func (p CookiePatternOrderField) IsValid() bool { switch p { - case CookiePatternOrderFieldCreatedAt: + case CookiePatternOrderFieldCreatedAt, + CookiePatternOrderFieldName, + CookiePatternOrderFieldLastMatchedAt, + CookiePatternOrderFieldUpdatedAt, + CookiePatternOrderFieldSource: return true } return false diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index e2e7814c8..4dc209015 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -177,6 +177,42 @@ func (r *cookieBannerResolver) ConsentRecords(ctx context.Context, obj *types.Co return types.NewCookieConsentRecordConnection(p, r, obj.ID, coredataFilter), nil } +// UncategorisedPatterns is the resolver for the uncategorisedPatterns field. +func (r *cookieBannerResolver) UncategorisedPatterns(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookiePatternOrderBy, filter *types.CookiePatternFilter) (*types.CookiePatternConnection, 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.ListUncategorisedCookiePatterns(ctx, scope, obj.ID, cursor, coredataFilter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list uncategorised cookie patterns", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + p := page.NewPage(patterns, cursor) + + return types.NewCookiePatternConnectionWithFilter(p, r, obj.ID, coredataFilter), 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) @@ -369,13 +405,22 @@ func (r *cookiePatternConnectionResolver) TotalCount(ctx context.Context, obj *t scope := coredata.NewScopeFromObjectID(obj.ParentID) - count, err := r.cookieBanner.CountCookiePatternsForCategory(ctx, scope, obj.ParentID) - if err != nil { - r.logger.ErrorCtx(ctx, "cannot count cookie patterns", log.Error(err)) - return 0, gqlutils.Internal(ctx) + switch obj.Resolver.(type) { + case *cookieBannerResolver: + count, err := r.cookieBanner.CountUncategorisedCookiePatterns(ctx, scope, obj.ParentID, obj.Filter) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count uncategorised cookie patterns", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + return count, nil + default: + count, err := r.cookieBanner.CountCookiePatternsForCategory(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count cookie patterns", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + return count, nil } - - return count, nil } // CreateCookieBanner is the resolver for the createCookieBanner field. diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 1e2717f4e..ed86ae7cf 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -123,6 +123,15 @@ type CookieBanner implements Node { filter: CookieConsentRecordFilter ): CookieConsentRecordConnection @goField(forceResolver: true) + uncategorisedPatterns( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: CookiePatternOrder + filter: CookiePatternFilter + ): CookiePatternConnection @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! @@ -184,6 +193,22 @@ enum CookiePatternOrderField @goEnum( value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldCreatedAt" ) + NAME + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldName" + ) + LAST_MATCHED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldLastMatchedAt" + ) + UPDATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldUpdatedAt" + ) + SOURCE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldSource" + ) } input CookiePatternOrder @@ -194,6 +219,11 @@ input CookiePatternOrder field: CookiePatternOrderField! } +input CookiePatternFilter { + query: String + source: CookieSource +} + type CookiePattern implements Node { id: ID! cookieCategory: CookieCategory @goField(forceResolver: true) diff --git a/pkg/server/api/console/v1/types/cookie_pattern.go b/pkg/server/api/console/v1/types/cookie_pattern.go index f4a539e8a..033ccae99 100644 --- a/pkg/server/api/console/v1/types/cookie_pattern.go +++ b/pkg/server/api/console/v1/types/cookie_pattern.go @@ -30,6 +30,7 @@ type ( Resolver any ParentID gid.GID + Filter *coredata.CookiePatternFilter } ) @@ -53,6 +54,17 @@ func NewCookiePatternConnection( } } +func NewCookiePatternConnectionWithFilter( + p *page.Page[*coredata.CookiePattern, coredata.CookiePatternOrderField], + parentType any, + parentID gid.GID, + filter *coredata.CookiePatternFilter, +) *CookiePatternConnection { + conn := NewCookiePatternConnection(p, parentType, parentID) + conn.Filter = filter + return conn +} + func NewCookiePatternEdge(cp *coredata.CookiePattern, orderBy coredata.CookiePatternOrderField) *CookiePatternEdge { return &CookiePatternEdge{ Cursor: cp.CursorKey(orderBy),