Every PromoteSource caller already loaded the tracker pattern under the same transaction, so a dedicated single-column UPDATE only duplicated machinery and forced callers to learn a second mutation verb. Add `source = @source` to Update's SET clause, mutate Source/UpdatedAt on the receiver, and call Update at the three promotion sites (worker merge loop, worker adoption loop, and reportDetectedTracker). The shouldPromoteSource gate still ranks the candidate against the loaded value; Update is now the single write path that can advance source, with a doc comment spelling out the load-first contract. Re-cast the coredata tests around Update: WritesSource pins the round-trip from receiver to DB, NotFoundForMissingRow preserves the ErrResourceNotFound contract callers rely on. The old OnlyTouchesSourceAndUpdatedAt test was a property of the narrow PromoteSource UPDATE and no longer applies — Update intentionally rewrites the full editable column set from the receiver. Signed-off-by: Émile Ré <emile@probo.com>
3101 lines
79 KiB
Go
3101 lines
79 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// Permission to use, copy, modify, and/or distribute this software for any
|
|
// purpose with or without fee is hereby granted, provided that the above
|
|
// copyright notice and this permission notice appear in all copies.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
// PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
package 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
|
|
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))
|
|
|
|
var categories coredata.CookieCategories
|
|
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, bannerID, consentFilter); err != nil {
|
|
return nil, fmt.Errorf("cannot load cookie categories: %w", err)
|
|
}
|
|
|
|
var allPatterns coredata.TrackerPatterns
|
|
if err := allPatterns.LoadAllByCookieBannerID(
|
|
ctx,
|
|
tx,
|
|
scope,
|
|
bannerID,
|
|
coredata.NewTrackerPatternFilter(nil, nil, new(false)),
|
|
nil,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("cannot load tracker patterns: %w", 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 {
|
|
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)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &version, 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 {
|
|
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))
|
|
|
|
var categories coredata.CookieCategories
|
|
if err := categories.LoadAllByCookieBannerID(ctx, conn, scope, banner.ID, consentFilter); err != nil {
|
|
return fmt.Errorf("cannot load cookie categories: %w", err)
|
|
}
|
|
|
|
var translations coredata.CookieBannerTranslations
|
|
if err := translations.LoadAllByCookieBannerID(ctx, conn, scope, banner.ID); err != nil {
|
|
return fmt.Errorf("cannot load cookie banner translations: %w", 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 {
|
|
return translations.LoadAllByCookieBannerID(ctx, conn, scope, cookieBannerID)
|
|
},
|
|
)
|
|
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,
|
|
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)
|
|
}
|
|
}
|
|
} 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)
|
|
}
|
|
|
|
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) 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
|
|
}
|