Add uncategorised patterns GraphQL endpoint

Backend for the cookie banner detection page: a new
uncategorisedPatterns connection on CookieBanner with
sortable (NAME, LAST_MATCHED_AT, UPDATED_AT, SOURCE)
and filterable (text ILIKE on name/description, source
enum) paginated results. COALESCE handles NULL-first
ordering for last_matched_at.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-04 18:30:47 +04:00
parent 54655c218b
commit bb442bb86d
7 changed files with 324 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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),