diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 1b79a08ad..42b0f63b9 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -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 { diff --git a/pkg/coredata/cookie.go b/pkg/coredata/cookie.go index 82e954f9c..07bc74822 100644 --- a/pkg/coredata/cookie.go +++ b/pkg/coredata/cookie.go @@ -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 diff --git a/pkg/coredata/cookie_pattern.go b/pkg/coredata/cookie_pattern.go new file mode 100644 index 000000000..477ea3690 --- /dev/null +++ b/pkg/coredata/cookie_pattern.go @@ -0,0 +1,488 @@ +// 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 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 +} diff --git a/pkg/coredata/cookie_pattern_match_type.go b/pkg/coredata/cookie_pattern_match_type.go new file mode 100644 index 000000000..7e5cf321c --- /dev/null +++ b/pkg/coredata/cookie_pattern_match_type.go @@ -0,0 +1,70 @@ +// 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 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) + } +} diff --git a/pkg/coredata/cookie_pattern_order_field.go b/pkg/coredata/cookie_pattern_order_field.go new file mode 100644 index 000000000..ed57c7390 --- /dev/null +++ b/pkg/coredata/cookie_pattern_order_field.go @@ -0,0 +1,55 @@ +// 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 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 +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index c2492478b..a1c883b46 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -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 } diff --git a/pkg/coredata/migrations/20260429T084452Z.sql b/pkg/coredata/migrations/20260429T084452Z.sql new file mode 100644 index 000000000..49b929060 --- /dev/null +++ b/pkg/coredata/migrations/20260429T084452Z.sql @@ -0,0 +1,63 @@ +-- 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. + +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; diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index 83b7b4c83..33660c71c 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -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 } diff --git a/pkg/server/api/console/v1/types/cookie.go b/pkg/server/api/console/v1/types/cookie.go index 5fa895696..69d661161 100644 --- a/pkg/server/api/console/v1/types/cookie.go +++ b/pkg/server/api/console/v1/types/cookie.go @@ -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, } }