Files
probo/pkg/cookiebanner/service.go
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

3339 lines
87 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package cookiebanner
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"slices"
"strconv"
"strings"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/uri"
"go.probo.inc/probo/pkg/validator"
)
type Service struct {
pg *pg.Client
showBranding bool
}
func NewService(pgClient *pg.Client, showBranding bool) *Service {
return &Service{pg: pgClient, showBranding: showBranding}
}
type (
CreateCookieBannerRequest struct {
OrganizationID gid.GID
Name string
Origin string
PrivacyPolicyURL *string
CookiePolicyURL string
ConsentExpiryDays int
}
CreateCookieCategoryRequest struct {
CookieBannerID gid.GID
Name string
Slug string
Description string
Rank int
}
UpdateCookieBannerRequest struct {
CookieBannerID gid.GID
Name *string
PrivacyPolicyURL *string
CookiePolicyURL *string
ConsentExpiryDays *int
DefaultLanguage *string
}
UpdateCookieCategoryRequest struct {
CookieCategoryID gid.GID
Name *string
Slug *string
Description *string
GCMConsentTypes *[]string
PostHogConsent *bool
}
ReorderCookieCategoryRequest struct {
CookieCategoryID gid.GID
Rank int
}
CreateCookieConsentRecordRequest struct {
CookieBannerID gid.GID
Version int
VisitorID string
IPAddress *string
UserAgent *string
ConsentData json.RawMessage
Action coredata.CookieConsentAction
SdkVersion string
}
RecordConsentRequest struct {
Version int
VisitorID string
IPAddress *string
UserAgent *string
ConsentData json.RawMessage
Action coredata.CookieConsentAction
SdkVersion string
Regulation *Regulation
RegulationSource coredata.RegulationSource
CountryCode *coredata.CountryCode
ConsentMode *coredata.CookieConsentMode
}
DetectedCookie struct {
Name string
MaxAgeSeconds *int
Source coredata.CookieSource
InitiatorURL *string
}
ReportDetectedCookiesRequest struct {
Cookies []DetectedCookie
}
DetectedStorageItem struct {
Key string
StorageType coredata.TrackerType
ValueSize *int
Source *coredata.CookieSource
InitiatorURL *string
}
DetectedResourceItem struct {
URL uri.URI
ResourceType coredata.TrackerResourceType
}
ReportDetectedTrackersRequest struct {
Cookies []DetectedCookie
Storage []DetectedStorageItem
Resources []DetectedResourceItem
}
CreateTrackerPatternRequest struct {
CookieCategoryID gid.GID
TrackerType coredata.TrackerType
Pattern string
MatchType coredata.TrackerPatternMatchType
DisplayName string
MaxAgeSeconds *int
Description string
Source *coredata.CookieSource
}
UpdateTrackerPatternRequest struct {
TrackerPatternID gid.GID
MaxAgeSeconds **int
Description *string
Excluded *bool
}
MoveTrackerPatternToCategoryRequest struct {
TrackerPatternID gid.GID
TargetCookieCategoryID gid.GID
}
MoveTrackerPatternToCategoryResult struct {
TrackerPattern *coredata.TrackerPattern
Banner *coredata.CookieBanner
}
CreateTrackerResourceRequest struct {
CookieCategoryID gid.GID
ResourceType coredata.TrackerResourceType
Origin string
Path string
DisplayName string
Description string
}
UpdateTrackerResourceRequest struct {
TrackerResourceID gid.GID
DisplayName *string
Description *string
Excluded *bool
}
MoveTrackerResourceToCategoryRequest struct {
TrackerResourceID gid.GID
TargetCookieCategoryID gid.GID
}
MoveTrackerResourceToCategoryResult struct {
TrackerResource *coredata.TrackerResource
Banner *coredata.CookieBanner
}
BannerConfig struct {
BannerID gid.GID `json:"banner_id"`
Version int `json:"version"`
Language string `json:"language"`
DefaultLanguage string `json:"default_language"`
PrivacyPolicyURL string `json:"privacy_policy_url,omitempty"`
CookiePolicyURL string `json:"cookie_policy_url"`
ConsentExpiryDays int `json:"consent_expiry_days"`
ConsentMode string `json:"consent_mode"`
Regulation Regulation `json:"regulation"`
ShowBranding bool `json:"show_branding"`
Categories []coredata.CookieBannerVersionSnapshotCategory `json:"categories"`
Texts map[string]string `json:"texts"`
}
UpsertCookieBannerTranslationRequest struct {
CookieBannerID gid.GID
Language string
Translations json.RawMessage
}
VisitorConsent struct {
VisitorID string `json:"visitor_id"`
Version int `json:"version"`
Action coredata.CookieConsentAction `json:"action"`
ConsentData json.RawMessage `json:"consent_data"`
CreatedAt time.Time `json:"created_at"`
}
)
func (r *CreateCookieBannerRequest) Validate() error {
v := validator.New()
v.Check(r.OrganizationID, "organization_id", validator.Required(), validator.GID(coredata.OrganizationEntityType))
v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Origin, "origin", validator.Required(), validator.Origin())
v.Check(r.PrivacyPolicyURL, "privacy_policy_url", validator.URL())
v.Check(r.CookiePolicyURL, "cookie_policy_url", validator.Required(), validator.URL())
v.Check(r.ConsentExpiryDays, "consent_expiry_days", validator.Required(), validator.Min(1))
return v.Error()
}
func (r *UpdateCookieBannerRequest) Validate() error {
v := validator.New()
v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(255))
v.Check(r.PrivacyPolicyURL, "privacy_policy_url", validator.URL())
v.Check(r.CookiePolicyURL, "cookie_policy_url", validator.URL())
v.Check(r.ConsentExpiryDays, "consent_expiry_days", validator.Min(1))
v.Check(r.DefaultLanguage, "default_language", validator.OneOfSlice(SupportedLanguages))
return v.Error()
}
func (r *CreateCookieCategoryRequest) Validate() error {
v := validator.New()
v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType))
v.Check(r.Name, "name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Slug, "slug", validator.Required(), validator.Slug(100))
v.Check(r.Description, "description", validator.Required(), validator.SafeText(1000))
v.Check(r.Rank, "rank", validator.Min(0))
return v.Error()
}
func (r *UpdateCookieCategoryRequest) Validate() error {
v := validator.New()
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(255))
v.Check(r.Slug, "slug", validator.Slug(100))
v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error()
}
func (r *ReorderCookieCategoryRequest) Validate() error {
v := validator.New()
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.Rank, "rank", validator.Min(0))
return v.Error()
}
func (r *CreateCookieConsentRecordRequest) Validate() error {
v := validator.New()
v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType))
v.Check(r.Version, "version", validator.Required(), validator.Min(1))
v.Check(r.VisitorID, "visitor_id", validator.Required(), validator.NotEmpty())
v.Check(r.Action, "action", validator.Required(), validator.OneOfSlice(coredata.CookieConsentActions()))
return v.Error()
}
func (r *RecordConsentRequest) Validate() error {
v := validator.New()
v.Check(r.Version, "version", validator.Required(), validator.Min(1))
v.Check(r.VisitorID, "visitor_id", validator.Required(), validator.NotEmpty())
v.Check(r.Action, "action", validator.Required(), validator.OneOfSlice(coredata.CookieConsentActions()))
return v.Error()
}
func (r *UpsertCookieBannerTranslationRequest) Validate() error {
v := validator.New()
v.Check(r.CookieBannerID, "cookie_banner_id", validator.Required(), validator.GID(coredata.CookieBannerEntityType))
v.Check(r.Language, "language", validator.Required(), validator.SafeTextNoNewLine(10))
var flat map[string]json.RawMessage
if err := json.Unmarshal(r.Translations, &flat); err != nil {
v.Check("", "translations", validator.Required())
return v.Error()
}
for key, raw := range flat {
if key == "categories" {
var cats map[string]json.RawMessage
if json.Unmarshal(raw, &cats) == nil {
for catID, catRaw := range cats {
var catFields map[string]json.RawMessage
if json.Unmarshal(catRaw, &catFields) == nil {
for field, fieldRaw := range catFields {
var s string
if json.Unmarshal(fieldRaw, &s) == nil {
v.Check(s, fmt.Sprintf("translations.categories.%s.%s", catID, field), validator.NoHTML(), validator.MaxLen(2000))
}
}
}
}
}
continue
}
var s string
if json.Unmarshal(raw, &s) != nil {
continue
}
validators := []validator.ValidatorFunc{validator.NoHTML(), validator.MaxLen(2000)}
if key == "banner_description" {
validators = append(validators, validator.ContainsSubstring("{{cookie_policy_link}}"))
}
v.Check(s, "translations."+key, validators...)
}
return v.Error()
}
func (r *CreateTrackerPatternRequest) Validate() error {
v := validator.New()
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(string(r.TrackerType), "tracker_type", validator.Required(), validator.OneOfSlice(
func() []string {
types := coredata.TrackerTypes()
s := make([]string, len(types))
for i, t := range types {
s[i] = string(t)
}
return s
}(),
))
v.Check(r.Pattern, "pattern", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(string(r.MatchType), "match_type", validator.Required(), validator.OneOfSlice(
func() []string {
types := coredata.TrackerPatternMatchTypes()
s := make([]string, len(types))
for i, t := range types {
s[i] = string(t)
}
return s
}(),
))
v.Check(r.Pattern, "pattern", func(value any) *validator.ValidationError {
s, _ := value.(string)
switch r.MatchType {
case coredata.TrackerPatternMatchTypeGlob:
if strings.Count(s, "*") != 1 {
return &validator.ValidationError{
Code: validator.ErrorCodeInvalidFormat,
Message: "glob pattern must contain exactly one *",
}
}
case coredata.TrackerPatternMatchTypeExact:
if strings.Contains(s, "*") {
return &validator.ValidationError{
Code: validator.ErrorCodeInvalidFormat,
Message: "exact pattern must not contain *",
}
}
}
return nil
})
v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error()
}
func (r *UpdateTrackerPatternRequest) Validate() error {
v := validator.New()
v.Check(r.TrackerPatternID, "tracker_pattern_id", validator.Required(), validator.GID(coredata.TrackerPatternEntityType))
if r.Description != nil {
v.Check(*r.Description, "description", validator.SafeText(1000))
}
return v.Error()
}
func (r *CreateTrackerResourceRequest) Validate() error {
v := validator.New()
v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(string(r.ResourceType), "resource_type", validator.Required(), validator.OneOfSlice(
func() []string {
types := coredata.TrackerResourceTypes()
s := make([]string, len(types))
for i, t := range types {
s[i] = string(t)
}
return s
}(),
))
v.Check(r.Origin, "origin", validator.Required(), validator.Origin())
v.Check(r.Path, "path", validator.Required(), validator.SafeTextNoNewLine(2048))
v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000))
return v.Error()
}
func (r *UpdateTrackerResourceRequest) Validate() error {
v := validator.New()
v.Check(r.TrackerResourceID, "tracker_resource_id", validator.Required(), validator.GID(coredata.TrackerResourceEntityType))
if r.DisplayName != nil {
v.Check(*r.DisplayName, "display_name", validator.SafeTextNoNewLine(255))
}
if r.Description != nil {
v.Check(*r.Description, "description", validator.SafeText(1000))
}
return v.Error()
}
func CanonicalizeOrigin(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
host := u.Hostname()
host = strings.TrimPrefix(host, "www.")
port := u.Port()
if port != "" {
return u.Scheme + "://" + host + ":" + port
}
return u.Scheme + "://" + host
}
func (s *Service) ensureDraftVersion(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
banner *coredata.CookieBanner,
categories coredata.CookieCategories,
allPatterns coredata.TrackerPatterns,
) (*coredata.CookieBannerVersion, error) {
snapshot := buildSnapshot(banner, categories, allPatterns)
var latest coredata.CookieBannerVersion
err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID)
if err == nil {
if latestSnapshot, snapErr := latest.GetSnapshot(); snapErr == nil && snapshotsEqual(snapshot, latestSnapshot) {
return &latest, nil
}
if latest.State == coredata.CookieBannerVersionStateDraft {
if err := latest.SetSnapshot(snapshot); err != nil {
return nil, fmt.Errorf("cannot set snapshot: %w", err)
}
latest.UpdatedAt = time.Now()
if err := latest.Update(ctx, tx, scope); err != nil {
return nil, fmt.Errorf("cannot update draft version: %w", err)
}
return &latest, nil
}
}
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return nil, fmt.Errorf("cannot load latest version: %w", err)
}
now := time.Now()
version := &coredata.CookieBannerVersion{
ID: gid.New(scope.GetTenantID(), coredata.CookieBannerVersionEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
State: coredata.CookieBannerVersionStateDraft,
CreatedAt: now,
UpdatedAt: now,
}
nextVersion, err := version.LoadNextVersion(ctx, tx, scope, banner.ID)
if err != nil {
return nil, fmt.Errorf("cannot determine next version: %w", err)
}
version.Version = nextVersion
if err := version.SetSnapshot(snapshot); err != nil {
return nil, fmt.Errorf("cannot set snapshot: %w", err)
}
if err := version.Insert(ctx, tx, scope); err != nil {
return nil, fmt.Errorf("cannot insert draft version: %w", err)
}
return version, nil
}
func (s *Service) ensureDraftVersionForBanner(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBannerVersion, error) {
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
return nil, fmt.Errorf("cannot load cookie banner: %w", err)
}
consentFilter := coredata.NewCookieCategoryFilter(new(coredata.CookieCategoryKindUncategorised))
categories, err := page.LoadAll(
ctx,
page.OrderBy[coredata.CookieCategoryOrderField]{
Field: coredata.CookieCategoryOrderFieldRank,
Direction: page.OrderDirectionAsc,
},
func(ctx context.Context, cursor *page.Cursor[coredata.CookieCategoryOrderField]) ([]*coredata.CookieCategory, error) {
var batch coredata.CookieCategories
if err := batch.LoadByCookieBannerID(ctx, tx, scope, bannerID, cursor, consentFilter); err != nil {
return nil, fmt.Errorf("cannot load cookie categories: %w", err)
}
return batch, nil
},
)
if err != nil {
return nil, err
}
allPatterns, err := page.LoadAll(
ctx,
page.OrderBy[coredata.TrackerPatternOrderField]{
Field: coredata.TrackerPatternOrderFieldCreatedAt,
Direction: page.OrderDirectionAsc,
},
func(ctx context.Context, cursor *page.Cursor[coredata.TrackerPatternOrderField]) ([]*coredata.TrackerPattern, error) {
var batch coredata.TrackerPatterns
if err := batch.LoadByCookieBannerID(ctx, tx, scope, bannerID, cursor, coredata.NewTrackerPatternFilter(nil, nil, new(false))); err != nil {
return nil, fmt.Errorf("cannot load tracker patterns: %w", err)
}
return batch, nil
},
)
if err != nil {
return nil, err
}
return s.ensureDraftVersion(ctx, tx, scope, &banner, categories, allPatterns)
}
func (s *Service) CreateCookieBanner(
ctx context.Context,
scope coredata.Scoper,
req CreateCookieBannerRequest,
) (*coredata.CookieBanner, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var banner *coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
now := time.Now()
banner = &coredata.CookieBanner{
ID: gid.New(scope.GetTenantID(), coredata.CookieBannerEntityType),
OrganizationID: req.OrganizationID,
Name: req.Name,
Origin: CanonicalizeOrigin(req.Origin),
State: coredata.CookieBannerStateActive,
PrivacyPolicyURL: req.PrivacyPolicyURL,
CookiePolicyURL: req.CookiePolicyURL,
ConsentExpiryDays: req.ConsentExpiryDays,
ShowBranding: s.showBranding,
DefaultLanguage: "en",
CreatedAt: now,
UpdatedAt: now,
}
if err := banner.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrOriginAlreadyInUse
}
return fmt.Errorf("cannot insert cookie banner: %w", err)
}
slugToGID := make(map[string]gid.GID, len(defaultCategories))
for _, dc := range defaultCategories {
gcmConsentTypes := dc.GCMConsentTypes
if gcmConsentTypes == nil {
gcmConsentTypes = []string{}
}
category := &coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
Name: dc.Name,
Slug: dc.Slug,
Description: dc.Description,
Kind: dc.Kind,
Rank: dc.Rank,
GCMConsentTypes: gcmConsentTypes,
PostHogConsent: dc.PostHogConsent,
CreatedAt: now,
UpdatedAt: now,
}
if err := category.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert default cookie category %q: %w", dc.Name, err)
}
slugToGID[dc.Slug] = category.ID
if dc.Kind == coredata.CookieCategoryKindNecessary {
consentMaxAge := req.ConsentExpiryDays * 86400
consentPattern := &coredata.TrackerPattern{
ID: gid.New(scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: category.ID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: "probo_consent",
MatchType: coredata.TrackerPatternMatchTypeExact,
DisplayName: "probo_consent",
MaxAgeSeconds: &consentMaxAge,
Description: "Stores your cookie consent preferences for this website.",
Source: new(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)
}
}
}
for lang, uiStrings := range defaultUIStringsByLanguage {
blob := make(map[string]any, len(uiStrings)+1)
for k, v := range uiStrings {
blob[k] = v
}
if catDefaults, ok := defaultCategoryTranslationsByLanguage[lang]; ok {
catMap := make(map[string]map[string]string, len(catDefaults))
for slug, ct := range catDefaults {
if id, exists := slugToGID[slug]; exists {
catMap[id.String()] = map[string]string{
"name": ct.Name,
"description": ct.Description,
}
}
}
if len(catMap) > 0 {
blob["categories"] = catMap
}
}
translationsJSON, err := json.Marshal(blob)
if err != nil {
return fmt.Errorf("cannot marshal default translations for %s: %w", lang, err)
}
translation := &coredata.CookieBannerTranslation{
ID: gid.New(scope.GetTenantID(), coredata.CookieBannerTranslationEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
Language: lang,
Translations: translationsJSON,
CreatedAt: now,
UpdatedAt: now,
}
if err := translation.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert default translation for %s: %w", lang, err)
}
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return banner, nil
}
func (s *Service) GetCookieBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBanner, error) {
var banner coredata.CookieBanner
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := banner.LoadByID(ctx, conn, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) GetCookieBannersByIDs(
ctx context.Context,
scope coredata.Scoper,
bannerIDs ...gid.GID,
) (coredata.CookieBanners, error) {
var banners coredata.CookieBanners
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := banners.LoadByIDs(ctx, conn, scope, bannerIDs); err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load cookie banners by ids: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return banners, nil
}
func (s *Service) GetActiveCookieBanner(
ctx context.Context,
bannerID gid.GID,
) (*coredata.CookieBanner, error) {
var banner coredata.CookieBanner
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := banner.LoadActiveByID(ctx, conn, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) ListCookieBannersForOrganization(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.CookieBannerOrderField],
filter *coredata.CookieBannerFilter,
) (coredata.CookieBanners, error) {
var banners coredata.CookieBanners
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := banners.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
return fmt.Errorf("cannot list cookie banners: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return banners, nil
}
func (s *Service) CountCookieBannersForOrganization(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
filter *coredata.CookieBannerFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
banners coredata.CookieBanners
err error
)
count, err = banners.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count cookie banners: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) UpdateCookieBanner(
ctx context.Context,
scope coredata.Scoper,
req UpdateCookieBannerRequest,
) (*coredata.CookieBanner, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var banner coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
nameChanged := req.Name != nil && *req.Name != banner.Name
privacyChanged := req.PrivacyPolicyURL != nil && !ptrEqual(req.PrivacyPolicyURL, banner.PrivacyPolicyURL)
cookiePolicyChanged := req.CookiePolicyURL != nil && *req.CookiePolicyURL != banner.CookiePolicyURL
expiryChanged := req.ConsentExpiryDays != nil && *req.ConsentExpiryDays != banner.ConsentExpiryDays
defaultLangChanged := req.DefaultLanguage != nil && *req.DefaultLanguage != banner.DefaultLanguage
snapshotChanged := privacyChanged || cookiePolicyChanged || expiryChanged || defaultLangChanged
if !nameChanged && !snapshotChanged {
return nil
}
if req.Name != nil {
banner.Name = *req.Name
}
if req.PrivacyPolicyURL != nil {
banner.PrivacyPolicyURL = req.PrivacyPolicyURL
}
if req.CookiePolicyURL != nil {
banner.CookiePolicyURL = *req.CookiePolicyURL
}
if req.ConsentExpiryDays != nil {
banner.ConsentExpiryDays = *req.ConsentExpiryDays
}
if req.DefaultLanguage != nil {
banner.DefaultLanguage = *req.DefaultLanguage
}
banner.UpdatedAt = time.Now()
if err := banner.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrOriginAlreadyInUse
}
return fmt.Errorf("cannot update cookie banner: %w", err)
}
if snapshotChanged {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) PublishCookieBannerVersion(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBannerVersion, error) {
var version coredata.CookieBannerVersion
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := version.LoadLatestByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrNoDraftVersion
}
return fmt.Errorf("cannot load latest version: %w", err)
}
if version.State != coredata.CookieBannerVersionStateDraft {
return ErrNoDraftVersion
}
version.State = coredata.CookieBannerVersionStatePublished
version.UpdatedAt = time.Now()
if err := version.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot publish version: %w", err)
}
banner := coredata.CookieBanner{ID: bannerID}
if err := banner.SetPolicyGenerationRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request tracker policy generation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &version, nil
}
// RegenerateTrackerPolicy re-arms tracker policy generation for a banner
// that already has a published version, so the tracker-policy worker
// regenerates the policy document (e.g. after iterating on the generator).
// It returns ErrNoPublishedVersion when nothing has been published yet.
func (s *Service) RegenerateTrackerPolicy(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBanner, error) {
var banner coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
var version coredata.CookieBannerVersion
if err := version.LoadLatestPublishedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrNoPublishedVersion
}
return fmt.Errorf("cannot load latest published version: %w", err)
}
if err := banner.SetPolicyGenerationRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request tracker policy generation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) ActivateCookieBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBanner, error) {
var banner coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
if banner.State == coredata.CookieBannerStateActive {
return ErrBannerAlreadyActive
}
banner.State = coredata.CookieBannerStateActive
banner.UpdatedAt = time.Now()
if err := banner.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrOriginAlreadyInUse
}
return fmt.Errorf("cannot update cookie banner: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) DeactivateCookieBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (*coredata.CookieBanner, error) {
var banner coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
if banner.State == coredata.CookieBannerStateInactive {
return ErrBannerAlreadyInactive
}
banner.State = coredata.CookieBannerStateInactive
banner.UpdatedAt = time.Now()
if err := banner.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update cookie banner: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) DeleteCookieBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
if err := banner.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete cookie banner: %w", err)
}
return nil
},
)
}
func (s *Service) CreateCookieCategory(
ctx context.Context,
scope coredata.Scoper,
req CreateCookieCategoryRequest,
) (*coredata.CookieCategory, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var category *coredata.CookieCategory
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
now := time.Now()
category = &coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: req.CookieBannerID,
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
Kind: coredata.CookieCategoryKindNormal,
Rank: req.Rank,
GCMConsentTypes: []string{},
CreatedAt: now,
UpdatedAt: now,
}
if err := category.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCategorySlugAlreadyExists
}
return fmt.Errorf("cannot insert cookie category: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, req.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return category, nil
}
func (s *Service) GetCookieCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
) (*coredata.CookieCategory, error) {
var category coredata.CookieCategory
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := category.LoadByID(ctx, conn, scope, categoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &category, nil
}
func (s *Service) GetCookieCategoriesByIDs(
ctx context.Context,
scope coredata.Scoper,
categoryIDs ...gid.GID,
) (coredata.CookieCategories, error) {
var categories coredata.CookieCategories
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := categories.LoadByIDs(ctx, conn, scope, categoryIDs); err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load cookie categories by ids: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return categories, nil
}
func (s *Service) ListCategoriesForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
cursor *page.Cursor[coredata.CookieCategoryOrderField],
filter *coredata.CookieCategoryFilter,
) (coredata.CookieCategories, error) {
var categories coredata.CookieCategories
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := categories.LoadByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil {
return fmt.Errorf("cannot list cookie categories: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return categories, nil
}
func (s *Service) CountCategoriesForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
filter *coredata.CookieCategoryFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
categories coredata.CookieCategories
err error
)
count, err = categories.CountByCookieBannerID(ctx, conn, scope, bannerID, filter)
if err != nil {
return fmt.Errorf("cannot count cookie categories: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) UpdateCookieCategory(
ctx context.Context,
scope coredata.Scoper,
req UpdateCookieCategoryRequest,
) (*coredata.CookieCategory, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var category coredata.CookieCategory
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
nameChanged := req.Name != nil && *req.Name != category.Name
slugChanged := req.Slug != nil && *req.Slug != category.Slug
descChanged := req.Description != nil && *req.Description != category.Description
gcmChanged := req.GCMConsentTypes != nil && !slices.Equal(*req.GCMConsentTypes, category.GCMConsentTypes)
posthogChanged := req.PostHogConsent != nil && *req.PostHogConsent != category.PostHogConsent
if !nameChanged && !slugChanged && !descChanged && !gcmChanged && !posthogChanged {
return nil
}
if req.Name != nil {
category.Name = *req.Name
}
if req.Slug != nil {
category.Slug = *req.Slug
}
if req.Description != nil {
category.Description = *req.Description
}
if req.GCMConsentTypes != nil {
category.GCMConsentTypes = *req.GCMConsentTypes
}
if posthogChanged {
if *req.PostHogConsent && category.Kind != coredata.CookieCategoryKindNormal {
return ErrPostHogConsentKindInvalid
}
if *req.PostHogConsent {
var categories coredata.CookieCategories
if err := categories.ClearPostHogConsentByBannerID(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot clear posthog consent: %w", err)
}
}
category.PostHogConsent = *req.PostHogConsent
}
category.UpdatedAt = time.Now()
if err := category.Update(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrCategorySlugAlreadyExists
}
return fmt.Errorf("cannot update cookie category: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &category, nil
}
func (s *Service) ReorderCookieCategory(
ctx context.Context,
scope coredata.Scoper,
req ReorderCookieCategoryRequest,
) (*coredata.CookieBanner, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var banner coredata.CookieBanner
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var category coredata.CookieCategory
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
if category.Rank == req.Rank {
return nil
}
category.Rank = req.Rank
category.UpdatedAt = time.Now()
if err := category.UpdateRank(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot reorder cookie category: %w", err)
}
// Rank is admin-only metadata; the snapshot is sorted by
// (Kind weight, ID) in buildSnapshot, so reordering does not
// affect visitor view and must not bump the version.
return nil
},
)
if err != nil {
return nil, err
}
return &banner, nil
}
func (s *Service) DeleteCookieCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var category coredata.CookieCategory
if err := category.LoadByID(ctx, tx, scope, categoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
if category.Kind != coredata.CookieCategoryKindNormal {
return ErrCannotDeleteSystemCategory
}
bannerID := category.CookieBannerID
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
}
var patterns coredata.TrackerPatterns
if err := patterns.MoveToCategoryByCookieCategoryID(ctx, tx, scope, category.ID, uncategorised.ID); err != nil {
return fmt.Errorf("cannot move tracker patterns to uncategorised: %w", err)
}
if err := category.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete cookie category: %w", err)
}
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
return nil
},
)
}
func (s *Service) GetCookieBannerVersion(
ctx context.Context,
scope coredata.Scoper,
versionID gid.GID,
) (*coredata.CookieBannerVersion, error) {
var version coredata.CookieBannerVersion
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := version.LoadByID(ctx, conn, scope, versionID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrVersionNotFound
}
return fmt.Errorf("cannot load cookie banner version: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &version, nil
}
func (s *Service) ListCookieBannerVersionsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
cursor *page.Cursor[coredata.CookieBannerVersionOrderField],
) (coredata.CookieBannerVersions, error) {
var versions coredata.CookieBannerVersions
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := versions.LoadByCookieBannerID(ctx, conn, scope, bannerID, cursor); err != nil {
return fmt.Errorf("cannot list cookie banner versions: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return versions, nil
}
func (s *Service) CountCookieBannerVersionsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
versions coredata.CookieBannerVersions
err error
)
count, err = versions.CountByCookieBannerID(ctx, conn, scope, bannerID)
if err != nil {
return fmt.Errorf("cannot count cookie banner versions: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) ListCookieConsentRecordsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
cursor *page.Cursor[coredata.CookieConsentRecordOrderField],
filter *coredata.CookieConsentRecordFilter,
) (coredata.CookieConsentRecords, error) {
var records coredata.CookieConsentRecords
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := records.LoadByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil {
return fmt.Errorf("cannot list consent records: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return records, nil
}
func (s *Service) GetCookieConsentRecord(
ctx context.Context,
scope coredata.Scoper,
id gid.GID,
) (*coredata.CookieConsentRecord, error) {
var record coredata.CookieConsentRecord
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := record.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrConsentNotFound
}
return fmt.Errorf("cannot load consent record: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &record, nil
}
func (s *Service) CountCookieConsentRecordsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
filter *coredata.CookieConsentRecordFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
records coredata.CookieConsentRecords
err error
)
count, err = records.CountByCookieBannerID(ctx, conn, scope, bannerID, filter)
if err != nil {
return fmt.Errorf("cannot count consent records: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) GetActiveBannerConfig(
ctx context.Context,
bannerID gid.GID,
lang string,
regulation Regulation,
sdkVersion string,
) (*BannerConfig, error) {
var config *BannerConfig
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var banner coredata.CookieBanner
if err := banner.LoadActiveByID(ctx, conn, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load active cookie banner: %w", err)
}
scope := coredata.NewScopeFromObjectID(banner.ID)
var version coredata.CookieBannerVersion
if err := version.LoadLatestPublishedByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrNoPublishedVersion
}
return fmt.Errorf("cannot load latest published version: %w", err)
}
snapshot, err := version.GetSnapshot()
if err != nil {
return fmt.Errorf("cannot get version snapshot: %w", err)
}
consentFilter := coredata.NewCookieCategoryFilter(new(coredata.CookieCategoryKindUncategorised))
categories, err := page.LoadAll(
ctx,
page.OrderBy[coredata.CookieCategoryOrderField]{
Field: coredata.CookieCategoryOrderFieldRank,
Direction: page.OrderDirectionAsc,
},
func(ctx context.Context, cursor *page.Cursor[coredata.CookieCategoryOrderField]) ([]*coredata.CookieCategory, error) {
var batch coredata.CookieCategories
if err := batch.LoadByCookieBannerID(ctx, conn, scope, banner.ID, cursor, consentFilter); err != nil {
return nil, fmt.Errorf("cannot load cookie categories: %w", err)
}
return batch, nil
},
)
if err != nil {
return err
}
translations, err := page.LoadAll(
ctx,
page.OrderBy[coredata.CookieBannerTranslationOrderField]{
Field: coredata.CookieBannerTranslationOrderFieldLanguage,
Direction: page.OrderDirectionAsc,
},
func(ctx context.Context, cursor *page.Cursor[coredata.CookieBannerTranslationOrderField]) ([]*coredata.CookieBannerTranslation, error) {
var batch coredata.CookieBannerTranslations
if err := batch.LoadByCookieBannerID(ctx, conn, scope, banner.ID, cursor); err != nil {
return nil, fmt.Errorf("cannot load cookie banner translations: %w", err)
}
return batch, nil
},
)
if err != nil {
return err
}
resolved := resolveTranslations(translations, categories)
config = buildBannerConfig(&banner, &version, &snapshot, resolved, lang)
return nil
},
)
if err != nil {
return nil, err
}
config.Regulation = regulation
config.ConsentMode = ConsentModeForRegulation(regulation)
if !isLegacySDK(sdkVersion) {
remapTextsForConsentMode(config.Texts, config.ConsentMode)
}
return config, nil
}
func buildBannerConfig(
banner *coredata.CookieBanner,
version *coredata.CookieBannerVersion,
snapshot *coredata.CookieBannerVersionSnapshot,
translations map[string]coredata.CookieBannerVersionSnapshotTranslation,
lang string,
) *BannerConfig {
defaultLang := snapshot.DefaultLanguage
if defaultLang == "" {
defaultLang = "en"
}
resolvedLang := defaultLang
if lang != "" {
if _, ok := translations[lang]; ok {
resolvedLang = lang
}
}
categories := make([]coredata.CookieBannerVersionSnapshotCategory, 0, len(snapshot.Categories))
for _, c := range snapshot.Categories {
if c.Kind != coredata.CookieCategoryKindUncategorised {
categories = append(categories, c)
}
}
texts := make(map[string]string)
if t, ok := translations[resolvedLang]; ok {
maps.Copy(texts, t.UI)
if len(t.Categories) == len(categories) {
translated := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories))
copy(translated, categories)
for i, ct := range t.Categories {
if ct.Name != "" {
translated[i].Name = ct.Name
}
if ct.Description != "" {
translated[i].Description = ct.Description
}
}
categories = translated
}
}
var privacyPolicyURL string
if snapshot.PrivacyPolicyURL != nil {
privacyPolicyURL = *snapshot.PrivacyPolicyURL
}
return &BannerConfig{
BannerID: banner.ID,
Version: version.Version,
Language: resolvedLang,
DefaultLanguage: defaultLang,
PrivacyPolicyURL: privacyPolicyURL,
CookiePolicyURL: snapshot.CookiePolicyURL,
ConsentExpiryDays: snapshot.ConsentExpiryDays,
ShowBranding: banner.ShowBranding,
Categories: categories,
Texts: texts,
}
}
// remapTextsForConsentMode overrides the generic banner text keys with
// mode-specific variants so the client renders the appropriate copy
// without needing consent-mode awareness itself.
func remapTextsForConsentMode(texts map[string]string, consentMode string) {
if texts == nil {
return
}
if consentMode == ConsentModeOptOut {
remapTextKey(texts, "banner_title_opt_out", "banner_title")
remapTextKey(texts, "banner_description_opt_out", "banner_description")
remapTextKey(texts, "button_acknowledge", "button_accept_all")
remapTextKey(texts, "button_opt_out", "button_reject_all")
texts["button_customize"] = ""
}
}
// isLegacySDK returns true when the SDK version is <= 0.2.x.
// Empty or unparseable versions are treated as current.
func isLegacySDK(version string) bool {
if version == "" {
return false
}
major, minor, ok := parseMajorMinor(version)
if !ok {
return false
}
return major == 0 && minor <= 2
}
func parseMajorMinor(version string) (major, minor int, ok bool) {
v := strings.TrimPrefix(version, "v")
parts := strings.SplitN(v, ".", 3)
if len(parts) < 2 {
return 0, 0, false
}
maj, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
min, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
return maj, min, true
}
func remapTextKey(texts map[string]string, src, dst string) {
if v, ok := texts[src]; ok && v != "" {
texts[dst] = v
}
}
func (s *Service) SetShowBranding(
ctx context.Context,
bannerID gid.GID,
show bool,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var banner coredata.CookieBanner
banner.ID = bannerID
if err := banner.UpdateShowBranding(ctx, tx, coredata.NewNoScope(), show); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot update show_branding: %w", err)
}
return nil
},
)
}
func (s *Service) UpsertCookieBannerTranslation(
ctx context.Context,
scope coredata.Scoper,
req UpsertCookieBannerTranslationRequest,
) (*coredata.CookieBannerTranslation, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var result *coredata.CookieBannerTranslation
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
now := time.Now()
var existing coredata.CookieBannerTranslation
err := existing.LoadByCookieBannerIDAndLanguage(ctx, tx, scope, req.CookieBannerID, req.Language)
if err == nil {
same, eqErr := jsonEqual(existing.Translations, req.Translations)
if eqErr == nil && same {
result = &existing
return nil
}
existing.Translations = req.Translations
existing.UpdatedAt = now
if err := existing.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update cookie banner translation: %w", err)
}
result = &existing
} else if errors.Is(err, coredata.ErrResourceNotFound) {
t := &coredata.CookieBannerTranslation{
ID: gid.New(scope.GetTenantID(), coredata.CookieBannerTranslationEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: req.CookieBannerID,
Language: req.Language,
Translations: req.Translations,
CreatedAt: now,
UpdatedAt: now,
}
if err := t.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert cookie banner translation: %w", err)
}
result = t
} else {
return fmt.Errorf("cannot load cookie banner translation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return result, nil
}
func (s *Service) ListCookieBannerTranslations(
ctx context.Context,
scope coredata.Scoper,
cookieBannerID gid.GID,
) (coredata.CookieBannerTranslations, error) {
var translations coredata.CookieBannerTranslations
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
loaded, err := page.LoadAll(
ctx,
page.OrderBy[coredata.CookieBannerTranslationOrderField]{
Field: coredata.CookieBannerTranslationOrderFieldLanguage,
Direction: page.OrderDirectionAsc,
},
func(ctx context.Context, cursor *page.Cursor[coredata.CookieBannerTranslationOrderField]) ([]*coredata.CookieBannerTranslation, error) {
var batch coredata.CookieBannerTranslations
if err := batch.LoadByCookieBannerID(ctx, conn, scope, cookieBannerID, cursor); err != nil {
return nil, fmt.Errorf("cannot load cookie banner translations: %w", err)
}
return batch, nil
},
)
if err != nil {
return err
}
translations = loaded
return nil
},
)
if err != nil {
return nil, err
}
return translations, nil
}
func (s *Service) GetVisitorConsent(
ctx context.Context,
bannerID gid.GID,
visitorID string,
) (*VisitorConsent, error) {
var consent *VisitorConsent
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var banner coredata.CookieBanner
if err := banner.LoadActiveByID(ctx, conn, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load active cookie banner: %w", err)
}
scope := coredata.NewScopeFromObjectID(banner.ID)
var record coredata.CookieConsentRecord
if err := record.LoadLatestByVisitorAndBannerID(ctx, conn, scope, banner.ID, visitorID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrConsentNotFound
}
return fmt.Errorf("cannot load consent record: %w", err)
}
var version coredata.CookieBannerVersion
if err := version.LoadByID(ctx, conn, scope, record.CookieBannerVersionID); err != nil {
return fmt.Errorf("cannot load cookie banner version: %w", err)
}
consent = &VisitorConsent{
VisitorID: record.VisitorID,
Version: version.Version,
Action: record.Action,
ConsentData: record.ConsentData,
CreatedAt: record.CreatedAt,
}
return nil
},
)
if err != nil {
return nil, err
}
return consent, nil
}
func (s *Service) RecordConsent(
ctx context.Context,
bannerID gid.GID,
req RecordConsentRequest,
) (*coredata.CookieConsentRecord, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
if req.IPAddress != nil {
anonymized := AnonymizeIP(*req.IPAddress)
req.IPAddress = &anonymized
}
var record *coredata.CookieConsentRecord
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var banner coredata.CookieBanner
if err := banner.LoadActiveByID(ctx, tx, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load active cookie banner: %w", err)
}
scope := coredata.NewScopeFromObjectID(banner.ID)
var publishedVersion coredata.CookieBannerVersion
if err := publishedVersion.LoadByCookieBannerIDAndVersion(ctx, tx, scope, banner.ID, req.Version); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrVersionNotFound
}
return fmt.Errorf("cannot load cookie banner version: %w", err)
}
if publishedVersion.State != coredata.CookieBannerVersionStatePublished {
return ErrVersionNotPublished
}
record = &coredata.CookieConsentRecord{
ID: gid.New(scope.GetTenantID(), coredata.CookieConsentRecordEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieBannerVersionID: publishedVersion.ID,
VisitorID: req.VisitorID,
IPAddress: req.IPAddress,
UserAgent: req.UserAgent,
ConsentData: req.ConsentData,
Action: req.Action,
SdkVersion: req.SdkVersion,
Regulation: req.Regulation,
RegulationSource: &req.RegulationSource,
CountryCode: req.CountryCode,
ConsentMode: req.ConsentMode,
CreatedAt: time.Now(),
}
if record.Regulation != nil && *record.Regulation == coredata.RegulationNone {
record.Regulation = nil
}
if err := record.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert consent record: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return record, nil
}
func (s *Service) ReportDetectedCookies(
ctx context.Context,
bannerID gid.GID,
req ReportDetectedCookiesRequest,
) error {
return s.ReportDetectedTrackers(ctx,
bannerID,
ReportDetectedTrackersRequest{
Cookies: req.Cookies,
},
)
}
func (s *Service) ReportDetectedTrackers(
ctx context.Context,
bannerID gid.GID,
req ReportDetectedTrackersRequest,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
scope := coredata.NewScopeFromObjectID(bannerID)
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrBannerNotFound
}
return fmt.Errorf("cannot load cookie banner: %w", err)
}
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil {
return fmt.Errorf("cannot load uncategorised category: %w", err)
}
inserted := 0
now := time.Now()
var matchedPatternIDs []gid.GID
for _, dc := range req.Cookies {
if err := s.reportDetectedTracker(
ctx,
tx,
scope,
&banner,
uncategorised.ID,
now,
detectedTrackerInfo{
TrackerType: coredata.TrackerTypeCookie,
Identifier: dc.Name,
MaxAgeSeconds: dc.MaxAgeSeconds,
Source: &dc.Source,
InitiatorURL: dc.InitiatorURL,
},
&inserted,
&matchedPatternIDs,
); err != nil {
return err
}
}
for _, ds := range req.Storage {
if err := s.reportDetectedTracker(
ctx,
tx,
scope,
&banner,
uncategorised.ID,
now,
detectedTrackerInfo{
TrackerType: ds.StorageType,
Identifier: ds.Key,
ValueSize: ds.ValueSize,
Source: ds.Source,
InitiatorURL: ds.InitiatorURL,
},
&inserted,
&matchedPatternIDs,
); err != nil {
return err
}
}
for _, dr := range req.Resources {
wasInserted, err := s.reportDetectedResource(
ctx,
tx,
scope,
&banner,
uncategorised.ID,
now,
dr,
)
if err != nil {
return err
}
if wasInserted {
inserted++
}
}
if len(matchedPatternIDs) > 0 {
var patterns coredata.TrackerPatterns
if err := patterns.UpdateLastMatchedAt(ctx, tx, scope, matchedPatternIDs, now); err != nil {
return fmt.Errorf("cannot update tracker pattern last_matched_at: %w", err)
}
}
if inserted > 0 {
if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request pattern analysis: %w", err)
}
}
return nil
},
)
}
type detectedTrackerInfo struct {
TrackerType coredata.TrackerType
Identifier string
MaxAgeSeconds *int
Source *coredata.CookieSource
ValueSize *int
InitiatorURL *string
}
func (s *Service) reportDetectedTracker(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
banner *coredata.CookieBanner,
uncategorisedID gid.GID,
now time.Time,
info detectedTrackerInfo,
inserted *int,
matchedPatternIDs *[]gid.GID,
) error {
var matchedPattern coredata.TrackerPattern
err := matchedPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, info.TrackerType, info.Identifier)
if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot find matching tracker pattern: %w", err)
}
if err == nil && matchedPattern.Excluded {
return nil
}
var patternID *gid.GID
if err == nil {
patternID = &matchedPattern.ID
*matchedPatternIDs = append(*matchedPatternIDs, matchedPattern.ID)
// A glob (or exact) pattern already covers this
// identifier, so no new exact pattern will be created
// and the merge/adoption loops in
// patternAnalysisHandler.Process will never see this
// detection. Promote the matched pattern's source here
// if the incoming detection carries a stronger signal,
// otherwise a pattern that started life as PRE_EXISTING
// (or EXTENSION) never advances even when subsequent
// SCRIPT-source detections confirm it as a real page
// tracker — last_matched_at would move forward but
// source would stay stale. shouldPromoteSource is a
// no-op when info.Source is nil or weaker, so storage
// items without a source and weaker re-detections cost
// nothing.
if shouldPromoteSource(matchedPattern.Source, info.Source) {
matchedPattern.Source = info.Source
matchedPattern.UpdatedAt = now
if err := matchedPattern.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot promote source on matched tracker pattern %q: %w", matchedPattern.Pattern, err)
}
// A stronger source can unblock mapping: the detection
// upserted below carries a fresh initiator domain that
// matchByDomain/matchBySiblingOrigin can now use. Re-arm
// mapping so the worker revisits the pattern.
if err := matchedPattern.SetMappingRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request mapping after source promotion on tracker pattern %q: %w", matchedPattern.Pattern, err)
}
}
} else {
newPattern := &coredata.TrackerPattern{
ID: gid.New(scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: uncategorisedID,
TrackerType: info.TrackerType,
Pattern: info.Identifier,
MatchType: coredata.TrackerPatternMatchTypeExact,
DisplayName: info.Identifier,
Description: "",
MaxAgeSeconds: info.MaxAgeSeconds,
Source: info.Source,
LastMatchedAt: &now,
MappingRequestedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
wasInserted, err := newPattern.InsertIfNotExists(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert tracker pattern: %w", err)
}
if wasInserted {
patternID = &newPattern.ID
*inserted++
} else {
var existingPattern coredata.TrackerPattern
if err := existingPattern.FindMatchingPattern(ctx, tx, scope, banner.ID, info.TrackerType, info.Identifier); err != nil {
return fmt.Errorf("cannot load existing tracker pattern: %w", err)
}
patternID = &existingPattern.ID
}
}
var initiatorDomain *string
if info.InitiatorURL != nil {
if domain := uri.ExtractDomain(*info.InitiatorURL); domain != "" {
initiatorDomain = &domain
}
}
tracker := &coredata.DetectedTracker{
ID: gid.New(scope.GetTenantID(), coredata.DetectedTrackerEntityType),
CookieBannerID: banner.ID,
TrackerPatternID: patternID,
TrackerType: info.TrackerType,
Identifier: info.Identifier,
MaxAgeSeconds: info.MaxAgeSeconds,
Source: info.Source,
ValueSize: info.ValueSize,
InitiatorURL: info.InitiatorURL,
InitiatorDomain: initiatorDomain,
LastDetectedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := tracker.Upsert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot upsert detected tracker: %w", err)
}
return nil
}
func (s *Service) reportDetectedResource(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
banner *coredata.CookieBanner,
uncategorisedID gid.GID,
now time.Time,
item DetectedResourceItem,
) (bool, error) {
u, err := url.Parse(item.URL.String())
if err != nil {
return false, fmt.Errorf("cannot parse resource URL: %w", err)
}
origin := u.Scheme + "://" + u.Host
path := u.Path
if path == "" {
path = "/"
}
resource := &coredata.TrackerResource{
ID: gid.New(scope.GetTenantID(), coredata.TrackerResourceEntityType),
OrganizationID: banner.OrganizationID,
CookieBannerID: banner.ID,
CookieCategoryID: uncategorisedID,
ResourceType: item.ResourceType,
Origin: origin,
Path: path,
DisplayName: u.Host + path,
Description: "",
LastDetectedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
inserted, err := resource.Upsert(ctx, tx, scope)
if err != nil {
return false, fmt.Errorf("cannot upsert tracker resource: %w", err)
}
return inserted, nil
}
func (s *Service) CreateTrackerPattern(
ctx context.Context,
scope coredata.Scoper,
req CreateTrackerPatternRequest,
) (*coredata.TrackerPattern, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var pattern *coredata.TrackerPattern
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var category coredata.CookieCategory
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
now := time.Now()
pattern = &coredata.TrackerPattern{
ID: gid.New(scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: category.CookieBannerID,
CookieCategoryID: category.ID,
TrackerType: req.TrackerType,
Pattern: req.Pattern,
MatchType: req.MatchType,
DisplayName: req.DisplayName,
MaxAgeSeconds: req.MaxAgeSeconds,
Description: req.Description,
Source: req.Source,
CreatedAt: now,
UpdatedAt: now,
}
if err := pattern.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrPatternAlreadyExists
}
return fmt.Errorf("cannot insert tracker pattern: %w", err)
}
if !pattern.Excluded && pattern.TrackerType == coredata.TrackerTypeCookie {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return pattern, nil
}
func (s *Service) ListTrackerPatternsForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
cursor *page.Cursor[coredata.TrackerPatternOrderField],
) (coredata.TrackerPatterns, error) {
var patterns coredata.TrackerPatterns
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return patterns.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor)
},
)
if err != nil {
return nil, fmt.Errorf("cannot list tracker patterns for category: %w", err)
}
return patterns, nil
}
func (s *Service) CountTrackerPatternsForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
patterns coredata.TrackerPatterns
err error
)
count, err = patterns.CountByCookieCategoryID(ctx, conn, scope, categoryID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("cannot count tracker patterns for category: %w", err)
}
return count, nil
}
func (s *Service) GetTrackerPattern(
ctx context.Context,
scope coredata.Scoper,
trackerPatternID gid.GID,
) (*coredata.TrackerPattern, error) {
var pattern coredata.TrackerPattern
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := pattern.LoadByID(ctx, conn, scope, trackerPatternID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerPatternNotFound
}
return fmt.Errorf("cannot load tracker pattern: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &pattern, nil
}
func (s *Service) UpdateTrackerPattern(
ctx context.Context,
scope coredata.Scoper,
req UpdateTrackerPatternRequest,
) (*coredata.TrackerPattern, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var pattern coredata.TrackerPattern
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := pattern.LoadByID(ctx, tx, scope, req.TrackerPatternID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerPatternNotFound
}
return fmt.Errorf("cannot load tracker pattern: %w", err)
}
maxAgeChanged := req.MaxAgeSeconds != nil && !ptrEqual(*req.MaxAgeSeconds, pattern.MaxAgeSeconds)
descChanged := req.Description != nil && *req.Description != pattern.Description
excludedChanged := req.Excluded != nil && *req.Excluded != pattern.Excluded
if !maxAgeChanged && !descChanged && !excludedChanged {
return nil
}
staysExcluded := pattern.Excluded && (req.Excluded == nil || *req.Excluded)
if req.MaxAgeSeconds != nil {
pattern.MaxAgeSeconds = *req.MaxAgeSeconds
}
if req.Description != nil {
pattern.Description = *req.Description
}
if req.Excluded != nil {
pattern.Excluded = *req.Excluded
}
pattern.UpdatedAt = time.Now()
if err := pattern.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker pattern: %w", err)
}
if !staysExcluded && pattern.TrackerType == coredata.TrackerTypeCookie {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, err
}
return &pattern, nil
}
func (s *Service) DeleteTrackerPattern(
ctx context.Context,
scope coredata.Scoper,
trackerPatternID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var pattern coredata.TrackerPattern
if err := pattern.LoadByID(ctx, tx, scope, trackerPatternID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerPatternNotFound
}
return fmt.Errorf("cannot load tracker pattern: %w", err)
}
wasExcluded := pattern.Excluded
if err := pattern.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete tracker pattern: %w", err)
}
if !wasExcluded && pattern.TrackerType == coredata.TrackerTypeCookie {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
return nil
},
)
}
func (s *Service) MoveTrackerPatternToCategory(
ctx context.Context,
scope coredata.Scoper,
req MoveTrackerPatternToCategoryRequest,
) (*MoveTrackerPatternToCategoryResult, error) {
var result MoveTrackerPatternToCategoryResult
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var pattern coredata.TrackerPattern
if err := pattern.LoadByID(ctx, tx, scope, req.TrackerPatternID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerPatternNotFound
}
return fmt.Errorf("cannot load tracker pattern: %w", err)
}
var target coredata.CookieCategory
if err := target.LoadByID(ctx, tx, scope, req.TargetCookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load target cookie category: %w", err)
}
if pattern.CookieCategoryID == target.ID {
return ErrSamePatternCategoryMove
}
if pattern.CookieBannerID != target.CookieBannerID {
return ErrCategoriesBannerMismatch
}
wasExcluded := pattern.Excluded
pattern.CookieCategoryID = target.ID
pattern.UpdatedAt = time.Now()
if err := pattern.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker pattern: %w", err)
}
// A manual move is the user's signal that this is a
// real tracker. Enqueue the tracker-mapping worker so
// it can promote the pattern to an org ThirdParty (or
// link an existing one) — never EXTENSION-sourced
// patterns, and never patterns we already promoted.
// SetMappingRequested is idempotent: it short-circuits
// when mapping_requested_at is already non-NULL.
if pattern.ThirdPartyID == nil &&
(pattern.Source == nil || *pattern.Source != coredata.CookieSourceExtension) {
if err := pattern.SetMappingRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot enqueue tracker mapping after move: %w", err)
}
}
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, pattern.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
if !wasExcluded && pattern.TrackerType == coredata.TrackerTypeCookie {
if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
}
result.TrackerPattern = &pattern
result.Banner = &banner
return nil
},
)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) ListTrackerPatternsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
cursor *page.Cursor[coredata.TrackerPatternOrderField],
filter *coredata.TrackerPatternFilter,
) (coredata.TrackerPatterns, error) {
var patterns coredata.TrackerPatterns
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := patterns.LoadByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil {
return fmt.Errorf("cannot list tracker patterns for banner: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return patterns, nil
}
func (s *Service) CountTrackerPatternsForBanner(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
filter *coredata.TrackerPatternFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
patterns coredata.TrackerPatterns
err error
)
count, err = patterns.CountByCookieBannerID(ctx, conn, scope, bannerID, filter)
if err != nil {
return fmt.Errorf("cannot count tracker patterns for banner: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) GetCommonTrackerPatternsByIDs(
ctx context.Context,
ids ...gid.GID,
) (coredata.CommonTrackerPatterns, error) {
var patterns coredata.CommonTrackerPatterns
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := patterns.LoadByIDs(ctx, conn, ids); err != nil {
return fmt.Errorf("cannot load common tracker patterns by ids: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return patterns, nil
}
// LoadDistinctThirdPartyIDsByCookieBannerID returns the distinct
// org-scoped third-party IDs referenced by tracker patterns of the
// banner. The companion
// LoadDistinctCommonTrackerPatternIDsByCookieBannerID covers the
// indirect mapping through common_tracker_patterns.
func (s *Service) LoadDistinctThirdPartyIDsByCookieBannerID(
ctx context.Context,
scope coredata.Scoper,
cookieBannerID gid.GID,
) ([]gid.GID, error) {
var ids []gid.GID
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
patterns coredata.TrackerPatterns
err error
)
ids, err = patterns.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, conn, scope, cookieBannerID)
if err != nil {
return fmt.Errorf("cannot load distinct third party ids: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return ids, nil
}
func (s *Service) LoadDistinctCommonTrackerPatternIDsByCookieBannerID(
ctx context.Context,
scope coredata.Scoper,
cookieBannerID gid.GID,
) ([]gid.GID, error) {
var ids []gid.GID
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
patterns coredata.TrackerPatterns
err error
)
ids, err = patterns.LoadDistinctCommonTrackerPatternIDsByCookieBannerID(ctx, conn, scope, cookieBannerID)
if err != nil {
return fmt.Errorf("cannot load distinct common tracker pattern ids: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return ids, nil
}
func (s *Service) CountDetectedTrackersByPatternID(
ctx context.Context,
scope coredata.Scoper,
trackerPatternID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
trackers coredata.DetectedTrackers
err error
)
count, err = trackers.CountByTrackerPatternID(ctx, conn, scope, trackerPatternID)
if err != nil {
return fmt.Errorf("cannot count detected trackers: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) ListDetectedTrackersForPattern(
ctx context.Context,
scope coredata.Scoper,
trackerPatternID gid.GID,
cursor *page.Cursor[coredata.DetectedTrackerOrderField],
) (coredata.DetectedTrackers, error) {
var trackers coredata.DetectedTrackers
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := trackers.LoadByTrackerPatternID(ctx, conn, scope, trackerPatternID, cursor); err != nil {
return fmt.Errorf("cannot list detected trackers for pattern: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return trackers, nil
}
func (s *Service) CreateTrackerResource(
ctx context.Context,
scope coredata.Scoper,
req CreateTrackerResourceRequest,
) (*coredata.TrackerResource, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var resource *coredata.TrackerResource
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var category coredata.CookieCategory
if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load cookie category: %w", err)
}
now := time.Now()
resource = &coredata.TrackerResource{
ID: gid.New(scope.GetTenantID(), coredata.TrackerResourceEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: category.CookieBannerID,
CookieCategoryID: category.ID,
ResourceType: req.ResourceType,
Origin: req.Origin,
Path: req.Path,
DisplayName: req.DisplayName,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}
if err := resource.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot insert tracker resource: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return resource, nil
}
func (s *Service) GetTrackerResource(
ctx context.Context,
scope coredata.Scoper,
trackerResourceID gid.GID,
) (*coredata.TrackerResource, error) {
var resource coredata.TrackerResource
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := resource.LoadByID(ctx, conn, scope, trackerResourceID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerResourceNotFound
}
return fmt.Errorf("cannot load tracker resource: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &resource, nil
}
func (s *Service) UpdateTrackerResource(
ctx context.Context,
scope coredata.Scoper,
req UpdateTrackerResourceRequest,
) (*coredata.TrackerResource, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var resource coredata.TrackerResource
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := resource.LoadByID(ctx, tx, scope, req.TrackerResourceID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerResourceNotFound
}
return fmt.Errorf("cannot load tracker resource: %w", err)
}
displayNameChanged := req.DisplayName != nil && *req.DisplayName != resource.DisplayName
descChanged := req.Description != nil && *req.Description != resource.Description
excludedChanged := req.Excluded != nil && *req.Excluded != resource.Excluded
if !displayNameChanged && !descChanged && !excludedChanged {
return nil
}
if req.DisplayName != nil {
resource.DisplayName = *req.DisplayName
}
if req.Description != nil {
resource.Description = *req.Description
}
if req.Excluded != nil {
resource.Excluded = *req.Excluded
}
resource.UpdatedAt = time.Now()
if err := resource.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker resource: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &resource, nil
}
func (s *Service) DeleteTrackerResource(
ctx context.Context,
scope coredata.Scoper,
trackerResourceID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var resource coredata.TrackerResource
if err := resource.LoadByID(ctx, tx, scope, trackerResourceID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerResourceNotFound
}
return fmt.Errorf("cannot load tracker resource: %w", err)
}
if err := resource.Delete(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot delete tracker resource: %w", err)
}
return nil
},
)
}
func (s *Service) MoveTrackerResourceToCategory(
ctx context.Context,
scope coredata.Scoper,
req MoveTrackerResourceToCategoryRequest,
) (*MoveTrackerResourceToCategoryResult, error) {
var result MoveTrackerResourceToCategoryResult
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var resource coredata.TrackerResource
if err := resource.LoadByID(ctx, tx, scope, req.TrackerResourceID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrTrackerResourceNotFound
}
return fmt.Errorf("cannot load tracker resource: %w", err)
}
var target coredata.CookieCategory
if err := target.LoadByID(ctx, tx, scope, req.TargetCookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load target cookie category: %w", err)
}
if resource.CookieCategoryID == target.ID {
return ErrSameResourceCategoryMove
}
if resource.CookieBannerID != target.CookieBannerID {
return ErrCategoriesBannerMismatch
}
resource.CookieCategoryID = target.ID
resource.UpdatedAt = time.Now()
if err := resource.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update tracker resource: %w", err)
}
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, resource.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
result.TrackerResource = &resource
result.Banner = &banner
return nil
},
)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) ListTrackerResourcesForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
cursor *page.Cursor[coredata.TrackerResourceOrderField],
) (coredata.TrackerResources, error) {
var resources coredata.TrackerResources
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return resources.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor)
},
)
if err != nil {
return nil, fmt.Errorf("cannot list tracker resources for category: %w", err)
}
return resources, nil
}
func (s *Service) CountTrackerResourcesForCategory(
ctx context.Context,
scope coredata.Scoper,
categoryID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
resources coredata.TrackerResources
err error
)
count, err = resources.CountByCookieCategoryID(ctx, conn, scope, categoryID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("cannot count tracker resources for category: %w", err)
}
return count, nil
}
func (s *Service) ListUncategorisedTrackerResources(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
cursor *page.Cursor[coredata.TrackerResourceOrderField],
filter *coredata.TrackerResourceFilter,
) (coredata.TrackerResources, error) {
var resources coredata.TrackerResources
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := resources.LoadUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, cursor, filter); err != nil {
return fmt.Errorf("cannot list uncategorised tracker resources: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return resources, nil
}
func (s *Service) CountUncategorisedTrackerResources(
ctx context.Context,
scope coredata.Scoper,
bannerID gid.GID,
filter *coredata.TrackerResourceFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var (
resources coredata.TrackerResources
err error
)
count, err = resources.CountUncategorisedByCookieBannerID(ctx, conn, scope, bannerID, filter)
if err != nil {
return fmt.Errorf("cannot count uncategorised tracker resources: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}