// Copyright (c) 2026 Probo Inc . // // 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 }