Add cookie pattern entity to group detected cookies

Introduce a cookie_patterns table that groups cookies sharing a common
prefix (e.g. phc_*) into a single manageable row. Every cookie now
belongs to a pattern (EXACT or PREFIX match type). Category, description,
and display metadata move from cookies to patterns, making patterns the
unit of management and display in the console and published snapshots.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-29 13:52:01 +04:00
parent 48606f34c1
commit f9fec45eb1
9 changed files with 980 additions and 143 deletions

View File

@@ -335,17 +335,17 @@ func CanonicalizeOrigin(raw string) string {
func buildSnapshot(
banner *coredata.CookieBanner,
categories coredata.CookieCategories,
allCookies coredata.Cookies,
allPatterns coredata.CookiePatterns,
translations coredata.CookieBannerTranslations,
) coredata.CookieBannerVersionSnapshot {
cookiesByCategory := make(map[gid.GID]coredata.CookieItems)
for _, c := range allCookies {
cookiesByCategory[c.CookieCategoryID] = append(
cookiesByCategory[c.CookieCategoryID],
for _, p := range allPatterns {
cookiesByCategory[p.CookieCategoryID] = append(
cookiesByCategory[p.CookieCategoryID],
coredata.CookieItem{
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
Name: p.DisplayName,
Duration: p.Duration,
Description: p.Description,
},
)
}
@@ -448,10 +448,10 @@ func (s *Service) ensureDraftVersion(
scope coredata.Scoper,
banner *coredata.CookieBanner,
categories coredata.CookieCategories,
allCookies coredata.Cookies,
allPatterns coredata.CookiePatterns,
translations coredata.CookieBannerTranslations,
) (*coredata.CookieBannerVersion, error) {
snapshot := buildSnapshot(banner, categories, allCookies, translations)
snapshot := buildSnapshot(banner, categories, allPatterns, translations)
var latest coredata.CookieBannerVersion
err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID)
@@ -514,9 +514,9 @@ func (s *Service) ensureDraftVersionForBanner(
return nil, fmt.Errorf("cannot load cookie categories: %w", err)
}
var allCookies coredata.Cookies
if err := allCookies.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookies: %w", err)
var allPatterns coredata.CookiePatterns
if err := allPatterns.LoadAllByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookie patterns: %w", err)
}
var translations coredata.CookieBannerTranslations
@@ -524,7 +524,7 @@ func (s *Service) ensureDraftVersionForBanner(
return nil, fmt.Errorf("cannot load cookie banner translations: %w", err)
}
return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allCookies, translations)
return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns, translations)
}
func (s *Service) CreateCookieBanner(
@@ -594,18 +594,35 @@ func (s *Service) CreateCookieBanner(
slugToGID[dc.Slug] = category.ID
if dc.Kind == coredata.CookieCategoryKindNecessary {
consentCookie := &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
consentPattern := &coredata.CookiePattern{
ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: category.ID,
Name: "probo_consent",
Pattern: "probo_consent",
MatchType: coredata.CookiePatternMatchTypeExact,
DisplayName: "probo_consent",
Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays),
Description: "Stores your cookie consent preferences for this website.",
Source: coredata.CookieSourceScript,
CreatedAt: now,
UpdatedAt: now,
}
if err := consentPattern.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert probo_consent pattern: %w", err)
}
consentCookie := &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookiePatternID: consentPattern.ID,
Name: "probo_consent",
Duration: fmt.Sprintf("%d days", req.ConsentExpiryDays),
Source: coredata.CookieSourceScript,
CreatedAt: now,
UpdatedAt: now,
}
if err := consentCookie.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert probo_consent cookie: %w", err)
}
@@ -1199,12 +1216,14 @@ func (s *Service) CreateCookie(
now := time.Now()
cookie = &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
pattern := &coredata.CookiePattern{
ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: category.CookieBannerID,
CookieCategoryID: category.ID,
Name: req.Name,
Pattern: req.Name,
MatchType: coredata.CookiePatternMatchTypeExact,
DisplayName: req.Name,
Duration: req.Duration,
Description: req.Description,
Source: coredata.CookieSourceScript,
@@ -1212,6 +1231,25 @@ func (s *Service) CreateCookie(
UpdatedAt: now,
}
if err := pattern.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCookieNameAlreadyExists
}
return fmt.Errorf("cannot insert cookie pattern: %w", err)
}
cookie = &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: category.CookieBannerID,
CookiePatternID: pattern.ID,
Name: req.Name,
Duration: req.Duration,
Source: coredata.CookieSourceScript,
CreatedAt: now,
UpdatedAt: now,
}
if err := cookie.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCookieNameAlreadyExists
@@ -1260,6 +1298,33 @@ func (s *Service) GetCookie(
return &cookie, nil
}
func (s *Service) GetCookiePattern(
ctx context.Context,
scope coredata.Scoper,
cookiePatternID gid.GID,
) (*coredata.CookiePattern, error) {
var pattern coredata.CookiePattern
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := pattern.LoadByID(ctx, conn, scope, cookiePatternID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCookieNotFound
}
return fmt.Errorf("cannot load cookie pattern: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &pattern, nil
}
func (s *Service) UpdateCookie(
ctx context.Context,
scope coredata.Scoper,
@@ -1281,22 +1346,13 @@ func (s *Service) UpdateCookie(
return fmt.Errorf("cannot load cookie: %w", err)
}
if req.Name != nil {
cookie.Name = *req.Name
}
if req.Duration != nil {
cookie.Duration = *req.Duration
}
if req.Description != nil {
cookie.Description = *req.Description
}
cookie.UpdatedAt = time.Now()
if err := cookie.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCookieNameAlreadyExists
}
return fmt.Errorf("cannot update cookie: %w", err)
}
@@ -1354,7 +1410,7 @@ func (s *Service) ListCookiesForCategory(
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := cookies.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor); err != nil {
if err := cookies.LoadByCookieCategoryIDViaPattern(ctx, conn, scope, categoryID, cursor); err != nil {
return fmt.Errorf("cannot list cookies: %w", err)
}
@@ -1381,7 +1437,7 @@ func (s *Service) CountCookiesForCategory(
var cookies coredata.Cookies
var err error
count, err = cookies.CountByCookieCategoryID(ctx, conn, scope, categoryID)
count, err = cookies.CountByCookieCategoryIDViaPattern(ctx, conn, scope, categoryID)
if err != nil {
return fmt.Errorf("cannot count cookies: %w", err)
}
@@ -1500,7 +1556,12 @@ func (s *Service) MoveCookieToCategory(
return fmt.Errorf("cannot load target cookie category: %w", err)
}
if cookie.CookieCategoryID == target.ID {
var pattern coredata.CookiePattern
if err := pattern.LoadByID(ctx, tx, scope, cookie.CookiePatternID); err != nil {
return fmt.Errorf("cannot load cookie pattern: %w", err)
}
if pattern.CookieCategoryID == target.ID {
return ErrSameCategoryMove
}
@@ -1508,11 +1569,11 @@ func (s *Service) MoveCookieToCategory(
return ErrCategoriesBannerMismatch
}
cookie.CookieCategoryID = target.ID
cookie.UpdatedAt = time.Now()
pattern.CookieCategoryID = target.ID
pattern.UpdatedAt = time.Now()
if err := cookie.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update cookie: %w", err)
if err := pattern.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update cookie pattern: %w", err)
}
var banner coredata.CookieBanner
@@ -1611,9 +1672,9 @@ func (s *Service) DeleteCookieCategory(
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
}
var cookies coredata.Cookies
if err := cookies.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil {
return fmt.Errorf("cannot move cookies to uncategorised: %w", err)
var patterns coredata.CookiePatterns
if err := patterns.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil {
return fmt.Errorf("cannot move cookie patterns to uncategorised: %w", err)
}
if err := category.Delete(ctx, tx, scope); err != nil {
@@ -2204,26 +2265,53 @@ func (s *Service) ReportDetectedCookies(
now := time.Now()
for _, dc := range req.Cookies {
cookie := &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: uncategorised.ID,
Name: dc.Name,
Duration: dc.Duration,
Description: "",
Source: dc.Source,
CreatedAt: now,
UpdatedAt: now,
var matchedPattern coredata.CookiePattern
err := matchedPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, dc.Name)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot find matching pattern: %w", err)
}
ok, err := cookie.InsertIfNotExists(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert detected cookie: %w", err)
}
if ok {
patternID := matchedPattern.ID
if errors.Is(err, coredata.ErrResourceNotFound) {
newPattern := &coredata.CookiePattern{
ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: uncategorised.ID,
Pattern: dc.Name,
MatchType: coredata.CookiePatternMatchTypeExact,
DisplayName: dc.Name,
Duration: dc.Duration,
Description: "",
Source: dc.Source,
CreatedAt: now,
UpdatedAt: now,
}
if err := newPattern.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
continue
}
return fmt.Errorf("cannot insert cookie pattern: %w", err)
}
patternID = newPattern.ID
inserted++
}
cookie := &coredata.Cookie{
ID: gid.New(scope.GetTenantID(), coredata.CookieEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookiePatternID: patternID,
Name: dc.Name,
Duration: dc.Duration,
Source: dc.Source,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := cookie.InsertIfNotExists(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert detected cookie: %w", err)
}
}
if inserted > 0 {

View File

@@ -30,16 +30,15 @@ import (
type (
Cookie struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CookieBannerID gid.GID `db:"cookie_banner_id"`
CookieCategoryID gid.GID `db:"cookie_category_id"`
Name string `db:"name"`
Duration string `db:"duration"`
Description string `db:"description"`
Source CookieSource `db:"source"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CookieBannerID gid.GID `db:"cookie_banner_id"`
CookiePatternID gid.GID `db:"cookie_pattern_id"`
Name string `db:"name"`
Duration string `db:"duration"`
Source CookieSource `db:"source"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Cookies []*Cookie
@@ -80,10 +79,9 @@ SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
cookie_pattern_id,
name,
duration,
description,
source,
created_at,
updated_at
@@ -118,7 +116,85 @@ LIMIT 1;
return nil
}
func (c *Cookies) LoadByCookieCategoryID(
func (c *Cookies) LoadByCookiePatternID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookiePatternID gid.GID,
cursor *page.Cursor[CookieOrderField],
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_pattern_id,
name,
duration,
source,
created_at,
updated_at
FROM
cookies
WHERE
%s
AND cookie_pattern_id = @cookie_pattern_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookies: %w", err)
}
cookies, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Cookie])
if err != nil {
return fmt.Errorf("cannot collect cookies: %w", err)
}
*c = cookies
return nil
}
func (c *Cookies) CountByCookiePatternID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookiePatternID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
cookies
WHERE
%s
AND cookie_pattern_id = @cookie_pattern_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID}
maps.Copy(args, scope.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 (c *Cookies) LoadByCookieCategoryIDViaPattern(
ctx context.Context,
conn pg.Querier,
scope Scoper,
@@ -130,10 +206,9 @@ SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
cookie_pattern_id,
name,
duration,
description,
source,
created_at,
updated_at
@@ -141,7 +216,9 @@ FROM
cookies
WHERE
%s
AND cookie_category_id = @cookie_category_id
AND cookie_pattern_id IN (
SELECT id FROM cookie_patterns WHERE cookie_category_id = @cookie_category_id
)
AND %s
`
@@ -166,7 +243,7 @@ WHERE
return nil
}
func (c *Cookies) CountByCookieCategoryID(
func (c *Cookies) CountByCookieCategoryIDViaPattern(
ctx context.Context,
conn pg.Querier,
scope Scoper,
@@ -179,7 +256,9 @@ FROM
cookies
WHERE
%s
AND cookie_category_id = @cookie_category_id
AND cookie_pattern_id IN (
SELECT id FROM cookie_patterns WHERE cookie_category_id = @cookie_category_id
)
`
q = fmt.Sprintf(q, scope.SQLFragment())
@@ -208,10 +287,9 @@ SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
cookie_pattern_id,
name,
duration,
description,
source,
created_at,
updated_at
@@ -255,10 +333,9 @@ INSERT INTO cookies (
tenant_id,
organization_id,
cookie_banner_id,
cookie_category_id,
cookie_pattern_id,
name,
duration,
description,
source,
created_at,
updated_at
@@ -267,10 +344,9 @@ INSERT INTO cookies (
@tenant_id,
@organization_id,
@cookie_banner_id,
@cookie_category_id,
@cookie_pattern_id,
@name,
@duration,
@description,
@source,
@created_at,
@updated_at
@@ -278,17 +354,16 @@ INSERT INTO cookies (
`
args := pgx.StrictNamedArgs{
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID,
"cookie_category_id": c.CookieCategoryID,
"name": c.Name,
"duration": c.Duration,
"description": c.Description,
"source": c.Source,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID,
"cookie_pattern_id": c.CookiePatternID,
"name": c.Name,
"duration": c.Duration,
"source": c.Source,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
_, err := tx.Exec(ctx, q, args)
@@ -315,10 +390,9 @@ INSERT INTO cookies (
tenant_id,
organization_id,
cookie_banner_id,
cookie_category_id,
cookie_pattern_id,
name,
duration,
description,
source,
created_at,
updated_at
@@ -327,10 +401,9 @@ INSERT INTO cookies (
@tenant_id,
@organization_id,
@cookie_banner_id,
@cookie_category_id,
@cookie_pattern_id,
@name,
@duration,
@description,
@source,
@created_at,
@updated_at
@@ -341,18 +414,17 @@ ON CONFLICT (cookie_banner_id, name) DO UPDATE
`
args := pgx.StrictNamedArgs{
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID,
"cookie_category_id": c.CookieCategoryID,
"name": c.Name,
"duration": c.Duration,
"description": c.Description,
"source": c.Source,
"source_script": CookieSourceScript,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"cookie_banner_id": c.CookieBannerID,
"cookie_pattern_id": c.CookiePatternID,
"name": c.Name,
"duration": c.Duration,
"source": c.Source,
"source_script": CookieSourceScript,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
result, err := tx.Exec(ctx, q, args)
@@ -371,10 +443,8 @@ func (c *Cookie) Update(
q := `
UPDATE cookies
SET
cookie_category_id = @cookie_category_id,
name = @name,
cookie_pattern_id = @cookie_pattern_id,
duration = @duration,
description = @description,
updated_at = @updated_at
WHERE
%s
@@ -384,22 +454,15 @@ WHERE
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": c.ID,
"cookie_category_id": c.CookieCategoryID,
"name": c.Name,
"duration": c.Duration,
"description": c.Description,
"updated_at": c.UpdatedAt,
"id": c.ID,
"cookie_pattern_id": c.CookiePatternID,
"duration": c.Duration,
"updated_at": c.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := tx.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookies_unique_name_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update cookie: %w", err)
}
@@ -435,35 +498,35 @@ WHERE
return nil
}
func (c *Cookies) MoveToCategoryByCookieCategoryID(
func (c *Cookies) RelinkByCookiePatternID(
ctx context.Context,
tx pg.Tx,
scope Scoper,
sourceCategoryID gid.GID,
targetCategoryID gid.GID,
sourcePatternID gid.GID,
targetPatternID gid.GID,
) error {
q := `
UPDATE cookies
SET
cookie_category_id = @target_category_id,
cookie_pattern_id = @target_pattern_id,
updated_at = @updated_at
WHERE
%s
AND cookie_category_id = @source_category_id
AND cookie_pattern_id = @source_pattern_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"source_category_id": sourceCategoryID,
"target_category_id": targetCategoryID,
"updated_at": time.Now(),
"source_pattern_id": sourcePatternID,
"target_pattern_id": targetPatternID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot move cookies to category: %w", err)
return fmt.Errorf("cannot relink cookies to pattern: %w", err)
}
return nil

View File

@@ -0,0 +1,488 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
CookiePattern struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CookieBannerID gid.GID `db:"cookie_banner_id"`
CookieCategoryID gid.GID `db:"cookie_category_id"`
Pattern string `db:"pattern"`
MatchType CookiePatternMatchType `db:"match_type"`
DisplayName string `db:"display_name"`
Duration string `db:"duration"`
Description string `db:"description"`
Source CookieSource `db:"source"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
CookiePatterns []*CookiePattern
)
func (cp *CookiePattern) CursorKey(field CookiePatternOrderField) page.CursorKey {
switch field {
case CookiePatternOrderFieldCreatedAt:
return page.NewCursorKey(cp.ID, cp.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (cp *CookiePattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
q := `SELECT organization_id FROM cookie_patterns WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, cp.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query cookie pattern authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (cp *CookiePattern) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookiePatternID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
pattern,
match_type,
display_name,
duration,
description,
source,
created_at,
updated_at
FROM
cookie_patterns
WHERE
%s
AND id = @cookie_pattern_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_pattern_id": cookiePatternID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookie patterns: %w", err)
}
pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect cookie pattern: %w", err)
}
*cp = pattern
return nil
}
func (cp *CookiePattern) FindMatchingPattern(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieBannerID gid.GID,
cookieName string,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
pattern,
match_type,
display_name,
duration,
description,
source,
created_at,
updated_at
FROM
cookie_patterns
WHERE
%s
AND cookie_banner_id = @cookie_banner_id
AND (
(match_type = @match_type_prefix AND starts_with(@cookie_name, pattern))
OR (match_type = @match_type_exact AND pattern = @cookie_name)
)
ORDER BY
CASE WHEN match_type = @match_type_prefix THEN 0 ELSE 1 END
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"cookie_banner_id": cookieBannerID,
"cookie_name": cookieName,
"match_type_prefix": CookiePatternMatchTypePrefix,
"match_type_exact": CookiePatternMatchTypeExact,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookie patterns: %w", err)
}
pattern, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookiePattern])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect cookie pattern: %w", err)
}
*cp = pattern
return nil
}
func (cps *CookiePatterns) LoadByCookieCategoryID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieCategoryID gid.GID,
cursor *page.Cursor[CookiePatternOrderField],
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
pattern,
match_type,
display_name,
duration,
description,
source,
created_at,
updated_at
FROM
cookie_patterns
WHERE
%s
AND cookie_category_id = @cookie_category_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookie patterns: %w", err)
}
patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern])
if err != nil {
return fmt.Errorf("cannot collect cookie patterns: %w", err)
}
*cps = patterns
return nil
}
func (cps *CookiePatterns) CountByCookieCategoryID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieCategoryID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
cookie_patterns
WHERE
%s
AND cookie_category_id = @cookie_category_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
maps.Copy(args, scope.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) LoadAllByCookieBannerID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
cookieBannerID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
cookie_banner_id,
cookie_category_id,
pattern,
match_type,
display_name,
duration,
description,
source,
created_at,
updated_at
FROM
cookie_patterns
WHERE
%s
AND cookie_banner_id = @cookie_banner_id
ORDER BY
created_at ASC, id ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query cookie patterns: %w", err)
}
patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookiePattern])
if err != nil {
return fmt.Errorf("cannot collect cookie patterns: %w", err)
}
*cps = patterns
return nil
}
func (cp *CookiePattern) Insert(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO cookie_patterns (
id,
tenant_id,
organization_id,
cookie_banner_id,
cookie_category_id,
pattern,
match_type,
display_name,
duration,
description,
source,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@cookie_banner_id,
@cookie_category_id,
@pattern,
@match_type,
@display_name,
@duration,
@description,
@source,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"id": cp.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": cp.OrganizationID,
"cookie_banner_id": cp.CookieBannerID,
"cookie_category_id": cp.CookieCategoryID,
"pattern": cp.Pattern,
"match_type": cp.MatchType,
"display_name": cp.DisplayName,
"duration": cp.Duration,
"description": cp.Description,
"source": cp.Source,
"created_at": cp.CreatedAt,
"updated_at": cp.UpdatedAt,
}
_, err := tx.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_patterns_unique_pattern_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert cookie pattern: %w", err)
}
return nil
}
func (cp *CookiePattern) Update(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
UPDATE cookie_patterns
SET
cookie_category_id = @cookie_category_id,
display_name = @display_name,
duration = @duration,
description = @description,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": cp.ID,
"cookie_category_id": cp.CookieCategoryID,
"display_name": cp.DisplayName,
"duration": cp.Duration,
"description": cp.Description,
"updated_at": cp.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
result, err := tx.Exec(ctx, q, args)
if err != nil {
if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok {
if pgErr.Code == "23505" && pgErr.ConstraintName == "idx_cookie_patterns_unique_pattern_per_banner" {
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update cookie pattern: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (cp *CookiePattern) Delete(
ctx context.Context,
tx pg.Tx,
scope Scoper,
) error {
q := `
DELETE FROM cookie_patterns
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": cp.ID}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete cookie pattern: %w", err)
}
return nil
}
func (cps *CookiePatterns) MoveToCategoryByCookieCategoryID(
ctx context.Context,
tx pg.Tx,
scope Scoper,
sourceCategoryID gid.GID,
targetCategoryID gid.GID,
) error {
q := `
UPDATE cookie_patterns
SET
cookie_category_id = @target_category_id,
updated_at = @updated_at
WHERE
%s
AND cookie_category_id = @source_category_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"source_category_id": sourceCategoryID,
"target_category_id": targetCategoryID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot move cookie patterns to category: %w", err)
}
return nil
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 coredata
import (
"database/sql/driver"
"fmt"
)
type CookiePatternMatchType string
const (
CookiePatternMatchTypeExact CookiePatternMatchType = "EXACT"
CookiePatternMatchTypePrefix CookiePatternMatchType = "PREFIX"
)
func CookiePatternMatchTypes() []CookiePatternMatchType {
return []CookiePatternMatchType{
CookiePatternMatchTypeExact,
CookiePatternMatchTypePrefix,
}
}
func (m CookiePatternMatchType) String() string {
return string(m)
}
func (m *CookiePatternMatchType) Scan(value any) error {
var v string
switch val := value.(type) {
case string:
v = val
case []byte:
v = string(val)
default:
return fmt.Errorf("unsupported type for CookiePatternMatchType: %T", value)
}
switch CookiePatternMatchType(v) {
case CookiePatternMatchTypeExact:
*m = CookiePatternMatchTypeExact
case CookiePatternMatchTypePrefix:
*m = CookiePatternMatchTypePrefix
default:
return fmt.Errorf("invalid CookiePatternMatchType value: %q", v)
}
return nil
}
func (m CookiePatternMatchType) Value() (driver.Value, error) {
switch m {
case CookiePatternMatchTypeExact,
CookiePatternMatchTypePrefix:
return string(m), nil
default:
return nil, fmt.Errorf("invalid CookiePatternMatchType: %s", m)
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 coredata
import "fmt"
type CookiePatternOrderField string
const (
CookiePatternOrderFieldCreatedAt CookiePatternOrderField = "CREATED_AT"
)
func (p CookiePatternOrderField) Column() string {
switch p {
case CookiePatternOrderFieldCreatedAt:
return "created_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
}
func (p CookiePatternOrderField) IsValid() bool {
switch p {
case CookiePatternOrderFieldCreatedAt:
return true
}
return false
}
func (p CookiePatternOrderField) String() string {
return string(p)
}
func (p *CookiePatternOrderField) UnmarshalText(text []byte) error {
*p = CookiePatternOrderField(text)
if !p.IsValid() {
return fmt.Errorf("%s is not a valid CookiePatternOrderField", string(text))
}
return nil
}
func (p CookiePatternOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}

View File

@@ -111,6 +111,7 @@ const (
CookieEntityType uint16 = 85
CookieBannerTranslationEntityType uint16 = 86
AgentRunEntityType uint16 = 87
CookiePatternEntityType uint16 = 88
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -281,6 +282,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &CookieBannerTranslation{ID: id}, true
case AgentRunEntityType:
return &AgentRun{ID: id}, true
case CookiePatternEntityType:
return &CookiePattern{ID: id}, true
default:
return nil, false
}

View File

@@ -0,0 +1,63 @@
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
--
-- 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.
CREATE TYPE cookie_pattern_match_type AS ENUM ('EXACT', 'PREFIX');
CREATE TABLE cookie_patterns (
id TEXT NOT NULL PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE,
cookie_category_id TEXT NOT NULL REFERENCES cookie_categories(id) ON DELETE CASCADE,
pattern TEXT NOT NULL,
match_type cookie_pattern_match_type NOT NULL,
display_name TEXT NOT NULL,
duration TEXT NOT NULL,
description TEXT NOT NULL,
source cookie_source NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE UNIQUE INDEX idx_cookie_patterns_unique_pattern_per_banner
ON cookie_patterns (cookie_banner_id, pattern);
-- Backfill: create an EXACT pattern for each existing cookie
INSERT INTO cookie_patterns (
id, tenant_id, organization_id, cookie_banner_id,
cookie_category_id, pattern, match_type, display_name,
duration, description, source, created_at, updated_at
)
SELECT
generate_gid(decode_base64_unpadded(c.tenant_id), 88),
c.tenant_id, c.organization_id, c.cookie_banner_id,
c.cookie_category_id, c.name, 'EXACT', c.name,
c.duration, c.description, c.source, c.created_at, c.updated_at
FROM cookies c;
-- Link each cookie to its pattern
ALTER TABLE cookies ADD COLUMN cookie_pattern_id TEXT REFERENCES cookie_patterns(id) ON DELETE CASCADE;
UPDATE cookies c
SET cookie_pattern_id = cp.id
FROM cookie_patterns cp
WHERE cp.cookie_banner_id = c.cookie_banner_id
AND cp.pattern = c.name
AND cp.match_type = 'EXACT';
ALTER TABLE cookies ALTER COLUMN cookie_pattern_id SET NOT NULL;
-- Category and description now live on the pattern
ALTER TABLE cookies DROP COLUMN cookie_category_id;
ALTER TABLE cookies DROP COLUMN description;

View File

@@ -319,7 +319,7 @@ func (r *cookieCategoryResolver) Cookies(ctx context.Context, obj *types.CookieC
p := page.NewPage(cookies, cursor)
return types.NewCookieConnection(p, r, obj.ID), nil
return types.NewCookieConnection(p, r, obj.ID, obj.ID), nil
}
// Permission is the resolver for the permission field.
@@ -759,7 +759,7 @@ func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types
}
return &types.MoveCookieToCategoryPayload{
Cookie: types.NewCookie(result.Cookie),
Cookie: types.NewCookie(result.Cookie, input.TargetCookieCategoryID),
CookieBanner: types.NewCookieBanner(result.Banner),
}, nil
}
@@ -804,7 +804,7 @@ func (r *mutationResolver) CreateCookie(ctx context.Context, input types.CreateC
}
return &types.CreateCookiePayload{
CookieEdge: types.NewCookieEdge(cookie, coredata.CookieOrderFieldCreatedAt),
CookieEdge: types.NewCookieEdge(cookie, coredata.CookieOrderFieldCreatedAt, input.CookieCategoryID),
CookieBanner: types.NewCookieBanner(banner),
}, nil
}
@@ -841,6 +841,13 @@ func (r *mutationResolver) UpdateCookie(ctx context.Context, input types.UpdateC
return nil, gqlutils.Internal(ctx)
}
patternScope := coredata.NewScopeFromObjectID(cookie.CookiePatternID)
pattern, err := r.cookieBanner.GetCookiePattern(ctx, patternScope, cookie.CookiePatternID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get cookie pattern", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
bannerScope := coredata.NewScopeFromObjectID(cookie.CookieBannerID)
banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, cookie.CookieBannerID)
if err != nil {
@@ -849,7 +856,7 @@ func (r *mutationResolver) UpdateCookie(ctx context.Context, input types.UpdateC
}
return &types.UpdateCookiePayload{
Cookie: types.NewCookie(cookie),
Cookie: types.NewCookie(cookie, pattern.CookieCategoryID),
CookieBanner: types.NewCookieBanner(banner),
}, nil
}

View File

@@ -37,11 +37,12 @@ func NewCookieConnection(
p *page.Page[*coredata.Cookie, coredata.CookieOrderField],
parentType any,
parentID gid.GID,
cookieCategoryID gid.GID,
) *CookieConnection {
edges := make([]*CookieEdge, len(p.Data))
for i := range edges {
edges[i] = NewCookieEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges[i] = NewCookieEdge(p.Data[i], p.Cursor.OrderBy.Field, cookieCategoryID)
}
return &CookieConnection{
@@ -53,27 +54,26 @@ func NewCookieConnection(
}
}
func NewCookieEdge(c *coredata.Cookie, orderBy coredata.CookieOrderField) *CookieEdge {
func NewCookieEdge(c *coredata.Cookie, orderBy coredata.CookieOrderField, cookieCategoryID gid.GID) *CookieEdge {
return &CookieEdge{
Cursor: c.CursorKey(orderBy),
Node: NewCookie(c),
Node: NewCookie(c, cookieCategoryID),
}
}
func NewCookie(c *coredata.Cookie) *Cookie {
func NewCookie(c *coredata.Cookie, cookieCategoryID gid.GID) *Cookie {
return &Cookie{
ID: c.ID,
CookieCategory: &CookieCategory{
ID: c.CookieCategoryID,
ID: cookieCategoryID,
CookieBanner: &CookieBanner{
ID: c.CookieBannerID,
},
},
Name: c.Name,
Duration: c.Duration,
Description: c.Description,
Source: c.Source,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
Name: c.Name,
Duration: c.Duration,
Source: c.Source,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}