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

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