diff --git a/pkg/cookiebanner/errors.go b/pkg/cookiebanner/errors.go index 56a0070c4..910d31fe5 100644 --- a/pkg/cookiebanner/errors.go +++ b/pkg/cookiebanner/errors.go @@ -19,8 +19,9 @@ import "errors" var ( ErrBannerNotFound = errors.New("cookie banner not found") ErrCategoryNotFound = errors.New("cookie category not found") - ErrBannerNotDraft = errors.New("cookie banner is not in draft state") - ErrBannerAlreadyPublished = errors.New("cookie banner is already published") - ErrBannerAlreadyDisabled = errors.New("cookie banner is already disabled") + ErrVersionNotFound = errors.New("cookie banner version not found") + ErrBannerAlreadyActive = errors.New("cookie banner is already active") + ErrBannerAlreadyInactive = errors.New("cookie banner is already inactive") + ErrVersionNotPublished = errors.New("cookie banner version is not published") ErrCannotDeleteRequiredCategory = errors.New("cannot delete required cookie category") ) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index da41ee6c8..35873048b 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -86,6 +86,7 @@ type ( CreateCookieConsentRecordRequest struct { CookieBannerID gid.GID + Version int VisitorID string IPAddress *string UserAgent *string @@ -146,6 +147,7 @@ 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())) @@ -173,7 +175,7 @@ func (s *Service) CreateCookieBanner( OrganizationID: req.OrganizationID, Name: req.Name, Origin: req.Origin, - State: coredata.CookieBannerStateDraft, + State: coredata.CookieBannerStateActive, PrivacyPolicyURL: req.PrivacyPolicyURL, ConsentExpiryDays: req.ConsentExpiryDays, ConsentMode: req.ConsentMode, @@ -316,11 +318,6 @@ func (s *Service) UpdateCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } - // TODO: remove this guard once we add versioning. - if banner.State != coredata.CookieBannerStateDraft { - return ErrBannerNotDraft - } - if req.Name != nil { banner.Name = *req.Name } @@ -353,7 +350,81 @@ func (s *Service) UpdateCookieBanner( return &banner, nil } -func (s *Service) PublishCookieBanner( +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 { + 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 categories coredata.CookieCategories + if err := categories.LoadAllPublicByCookieBannerID(ctx, tx, bannerID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + snapshotCategories := make([]coredata.CookieBannerVersionSnapshotCategory, len(categories)) + for i, c := range categories { + snapshotCategories[i] = coredata.CookieBannerVersionSnapshotCategory{ + Name: c.Name, + Description: c.Description, + Required: c.Required, + Cookies: c.Cookies, + } + } + + snapshot := coredata.CookieBannerVersionSnapshot{ + PrivacyPolicyURL: banner.PrivacyPolicyURL, + ConsentExpiryDays: banner.ConsentExpiryDays, + ConsentMode: string(banner.ConsentMode), + Categories: snapshotCategories, + } + + now := time.Now() + + version = &coredata.CookieBannerVersion{ + ID: gid.New(scope.GetTenantID(), coredata.CookieBannerVersionEntityType), + CookieBannerID: bannerID, + State: coredata.CookieBannerVersionStatePublished, + CreatedAt: now, + UpdatedAt: now, + } + + nextVersion, err := version.LoadNextVersion(ctx, tx, scope, bannerID) + if err != nil { + return fmt.Errorf("cannot determine next version: %w", err) + } + version.Version = nextVersion + + if err := version.SetSnapshot(snapshot); err != nil { + return fmt.Errorf("cannot set snapshot: %w", err) + } + + if err := version.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert cookie banner 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, @@ -370,11 +441,11 @@ func (s *Service) PublishCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } - if banner.State == coredata.CookieBannerStatePublished { - return ErrBannerAlreadyPublished + if banner.State == coredata.CookieBannerStateActive { + return ErrBannerAlreadyActive } - banner.State = coredata.CookieBannerStatePublished + banner.State = coredata.CookieBannerStateActive banner.UpdatedAt = time.Now() if err := banner.Update(ctx, tx, scope); err != nil { @@ -391,7 +462,7 @@ func (s *Service) PublishCookieBanner( return &banner, nil } -func (s *Service) DisableCookieBanner( +func (s *Service) DeactivateCookieBanner( ctx context.Context, scope coredata.Scoper, bannerID gid.GID, @@ -408,11 +479,11 @@ func (s *Service) DisableCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } - if banner.State == coredata.CookieBannerStateDisabled { - return ErrBannerAlreadyDisabled + if banner.State == coredata.CookieBannerStateInactive { + return ErrBannerAlreadyInactive } - banner.State = coredata.CookieBannerStateDisabled + banner.State = coredata.CookieBannerStateInactive banner.UpdatedAt = time.Now() if err := banner.Update(ctx, tx, scope); err != nil { @@ -445,10 +516,6 @@ func (s *Service) DeleteCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } - if banner.State != coredata.CookieBannerStateDraft { - return ErrBannerNotDraft - } - if err := banner.Delete(ctx, tx, scope); err != nil { return fmt.Errorf("cannot delete cookie banner: %w", err) } @@ -664,6 +731,86 @@ func (s *Service) DeleteCookieCategory( ) } +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 + var 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) CreateCookieConsentRecord( ctx context.Context, scope coredata.Scoper, @@ -678,15 +825,28 @@ func (s *Service) CreateCookieConsentRecord( err := s.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + var publishedVersion coredata.CookieBannerVersion + if err := publishedVersion.LoadByCookieBannerIDAndVersion(ctx, tx, scope, req.CookieBannerID, 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), - CookieBannerID: req.CookieBannerID, - VisitorID: req.VisitorID, - IPAddress: req.IPAddress, - UserAgent: req.UserAgent, - ConsentData: req.ConsentData, - Action: req.Action, - CreatedAt: time.Now(), + ID: gid.New(scope.GetTenantID(), coredata.CookieConsentRecordEntityType), + CookieBannerID: req.CookieBannerID, + CookieBannerVersionID: publishedVersion.ID, + VisitorID: req.VisitorID, + IPAddress: req.IPAddress, + UserAgent: req.UserAgent, + ConsentData: req.ConsentData, + Action: req.Action, + CreatedAt: time.Now(), } if err := record.Insert(ctx, tx, scope); err != nil { diff --git a/pkg/coredata/cookie_banner.go b/pkg/coredata/cookie_banner.go index 614c5c398..2decaf4b8 100644 --- a/pkg/coredata/cookie_banner.go +++ b/pkg/coredata/cookie_banner.go @@ -118,7 +118,7 @@ LIMIT 1; return nil } -func (b *CookieBanner) LoadPublishedByID( +func (b *CookieBanner) LoadActiveByID( ctx context.Context, conn pg.Querier, bannerID gid.GID, @@ -139,7 +139,7 @@ FROM cookie_banners WHERE id = @banner_id - AND state = 'PUBLISHED' + AND state = 'ACTIVE' LIMIT 1; ` diff --git a/pkg/coredata/cookie_banner_state.go b/pkg/coredata/cookie_banner_state.go index 5ac6efc75..85cf6ab64 100644 --- a/pkg/coredata/cookie_banner_state.go +++ b/pkg/coredata/cookie_banner_state.go @@ -22,16 +22,14 @@ import ( type CookieBannerState string const ( - CookieBannerStateDraft CookieBannerState = "DRAFT" - CookieBannerStatePublished CookieBannerState = "PUBLISHED" - CookieBannerStateDisabled CookieBannerState = "DISABLED" + CookieBannerStateActive CookieBannerState = "ACTIVE" + CookieBannerStateInactive CookieBannerState = "INACTIVE" ) func CookieBannerStates() []CookieBannerState { return []CookieBannerState{ - CookieBannerStateDraft, - CookieBannerStatePublished, - CookieBannerStateDisabled, + CookieBannerStateActive, + CookieBannerStateInactive, } } @@ -51,12 +49,10 @@ func (s *CookieBannerState) Scan(value any) error { } switch CookieBannerState(v) { - case CookieBannerStateDraft: - *s = CookieBannerStateDraft - case CookieBannerStatePublished: - *s = CookieBannerStatePublished - case CookieBannerStateDisabled: - *s = CookieBannerStateDisabled + case CookieBannerStateActive: + *s = CookieBannerStateActive + case CookieBannerStateInactive: + *s = CookieBannerStateInactive default: return fmt.Errorf("invalid CookieBannerState value: %q", v) } @@ -65,9 +61,8 @@ func (s *CookieBannerState) Scan(value any) error { func (s CookieBannerState) Value() (driver.Value, error) { switch s { - case CookieBannerStateDraft, - CookieBannerStatePublished, - CookieBannerStateDisabled: + case CookieBannerStateActive, + CookieBannerStateInactive: return string(s), nil default: return nil, fmt.Errorf("invalid CookieBannerState: %s", s) diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go new file mode 100644 index 000000000..0b763b6d0 --- /dev/null +++ b/pkg/coredata/cookie_banner_version.go @@ -0,0 +1,391 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CookieBannerVersionSnapshot struct { + PrivacyPolicyURL string `json:"privacy_policy_url"` + ConsentExpiryDays int `json:"consent_expiry_days"` + ConsentMode string `json:"consent_mode"` + Categories []CookieBannerVersionSnapshotCategory `json:"categories"` + } + + CookieBannerVersionSnapshotCategory struct { + Name string `json:"name"` + Description string `json:"description"` + Required bool `json:"required"` + Cookies CookieItems `json:"cookies"` + } + + CookieBannerVersion struct { + ID gid.GID `db:"id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + Version int `db:"version"` + State CookieBannerVersionState `db:"state"` + Snapshot json.RawMessage `db:"snapshot"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + CookieBannerVersions []*CookieBannerVersion +) + +func (v *CookieBannerVersion) CursorKey(field CookieBannerVersionOrderField) page.CursorKey { + switch field { + case CookieBannerVersionOrderFieldCreatedAt: + return page.NewCursorKey(v.ID, v.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", field)) +} + +func (v *CookieBannerVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { + q := ` +SELECT cb.organization_id +FROM cookie_banner_versions cbv +JOIN cookie_banners cb ON cbv.cookie_banner_id = cb.id +WHERE cbv.id = $1 +LIMIT 1; +` + + var organizationID gid.GID + if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrResourceNotFound + } + return nil, fmt.Errorf("cannot query cookie banner version authorization attributes: %w", err) + } + + return map[string]string{"organization_id": organizationID.String()}, nil +} + +func (v *CookieBannerVersion) GetSnapshot() (CookieBannerVersionSnapshot, error) { + var snapshot CookieBannerVersionSnapshot + if err := json.Unmarshal(v.Snapshot, &snapshot); err != nil { + return snapshot, fmt.Errorf("cannot unmarshal cookie banner version snapshot: %w", err) + } + return snapshot, nil +} + +func (v *CookieBannerVersion) SetSnapshot(snapshot CookieBannerVersionSnapshot) error { + data, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("cannot marshal cookie banner version snapshot: %w", err) + } + v.Snapshot = data + return nil +} + +func (v *CookieBannerVersion) LoadByID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + versionID gid.GID, +) error { + q := ` +SELECT + id, + cookie_banner_id, + version, + state, + snapshot, + created_at, + updated_at +FROM + cookie_banner_versions +WHERE + %s + AND id = @version_id +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"version_id": versionID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banner versions: %w", err) + } + + version, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieBannerVersion]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie banner version: %w", err) + } + + *v = version + + return nil +} + +func (v *CookieBannerVersions) LoadByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + cursor *page.Cursor[CookieBannerVersionOrderField], +) error { + q := ` +SELECT + id, + cookie_banner_id, + version, + state, + snapshot, + created_at, + updated_at +FROM + cookie_banner_versions +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND %s +` + + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banner versions: %w", err) + } + + versions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[CookieBannerVersion]) + if err != nil { + return fmt.Errorf("cannot collect cookie banner versions: %w", err) + } + + *v = versions + + return nil +} + +func (v *CookieBannerVersions) CountByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(id) +FROM + cookie_banner_versions +WHERE + %s + AND cookie_banner_id = @cookie_banner_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot scan count: %w", err) + } + + return count, nil +} + +func (v *CookieBannerVersion) LoadByCookieBannerIDAndVersion( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, + version int, +) error { + q := ` +SELECT + id, + cookie_banner_id, + version, + state, + snapshot, + created_at, + updated_at +FROM + cookie_banner_versions +WHERE + %s + AND cookie_banner_id = @cookie_banner_id + AND version = @version +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "cookie_banner_id": cookieBannerID, + "version": version, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie banner versions: %w", err) + } + + ver, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieBannerVersion]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + return fmt.Errorf("cannot collect cookie banner version: %w", err) + } + + *v = ver + + return nil +} + +func (v *CookieBannerVersion) LoadNextVersion( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) (int, error) { + q := ` +SELECT + COALESCE(MAX(version), 0) + 1 +FROM + cookie_banner_versions +WHERE + %s + AND cookie_banner_id = @cookie_banner_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var nextVersion int + if err := row.Scan(&nextVersion); err != nil { + return 0, fmt.Errorf("cannot scan next version: %w", err) + } + + return nextVersion, nil +} + +func (v *CookieBannerVersion) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO cookie_banner_versions ( + id, + tenant_id, + cookie_banner_id, + version, + state, + snapshot, + created_at, + updated_at +) VALUES ( + @id, + @tenant_id, + @cookie_banner_id, + @version, + @state, + @snapshot, + @created_at, + @updated_at +) +` + + args := pgx.StrictNamedArgs{ + "id": v.ID, + "tenant_id": scope.GetTenantID(), + "cookie_banner_id": v.CookieBannerID, + "version": v.Version, + "state": v.State, + "snapshot": v.Snapshot, + "created_at": v.CreatedAt, + "updated_at": v.UpdatedAt, + } + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot insert cookie banner version: %w", err) + } + + return nil +} + +func (v *CookieBannerVersion) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE cookie_banner_versions +SET + state = @state, + snapshot = @snapshot, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": v.ID, + "state": v.State, + "snapshot": v.Snapshot, + "updated_at": v.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update cookie banner version: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} diff --git a/pkg/coredata/cookie_banner_version_order_field.go b/pkg/coredata/cookie_banner_version_order_field.go new file mode 100644 index 000000000..78ef0411f --- /dev/null +++ b/pkg/coredata/cookie_banner_version_order_field.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import "fmt" + +type CookieBannerVersionOrderField string + +const ( + CookieBannerVersionOrderFieldCreatedAt CookieBannerVersionOrderField = "CREATED_AT" +) + +func (p CookieBannerVersionOrderField) Column() string { + switch p { + case CookieBannerVersionOrderFieldCreatedAt: + return "created_at" + } + panic(fmt.Sprintf("unsupported order by: %s", p)) +} + +func (p CookieBannerVersionOrderField) IsValid() bool { + switch p { + case CookieBannerVersionOrderFieldCreatedAt: + return true + } + return false +} + +func (p CookieBannerVersionOrderField) String() string { + return string(p) +} + +func (p *CookieBannerVersionOrderField) UnmarshalText(text []byte) error { + *p = CookieBannerVersionOrderField(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid CookieBannerVersionOrderField", string(text)) + } + return nil +} + +func (p CookieBannerVersionOrderField) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/cookie_banner_version_state.go b/pkg/coredata/cookie_banner_version_state.go new file mode 100644 index 000000000..cb90e4e22 --- /dev/null +++ b/pkg/coredata/cookie_banner_version_state.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type CookieBannerVersionState string + +const ( + CookieBannerVersionStateDraft CookieBannerVersionState = "DRAFT" + CookieBannerVersionStatePublished CookieBannerVersionState = "PUBLISHED" +) + +func CookieBannerVersionStates() []CookieBannerVersionState { + return []CookieBannerVersionState{ + CookieBannerVersionStateDraft, + CookieBannerVersionStatePublished, + } +} + +func (s CookieBannerVersionState) String() string { + return string(s) +} + +func (s *CookieBannerVersionState) Scan(value any) error { + var v string + switch val := value.(type) { + case string: + v = val + case []byte: + v = string(val) + default: + return fmt.Errorf("unsupported type for CookieBannerVersionState: %T", value) + } + + switch CookieBannerVersionState(v) { + case CookieBannerVersionStateDraft: + *s = CookieBannerVersionStateDraft + case CookieBannerVersionStatePublished: + *s = CookieBannerVersionStatePublished + default: + return fmt.Errorf("invalid CookieBannerVersionState value: %q", v) + } + return nil +} + +func (s CookieBannerVersionState) Value() (driver.Value, error) { + switch s { + case CookieBannerVersionStateDraft, + CookieBannerVersionStatePublished: + return string(s), nil + default: + return nil, fmt.Errorf("invalid CookieBannerVersionState: %s", s) + } +} diff --git a/pkg/coredata/cookie_consent_record.go b/pkg/coredata/cookie_consent_record.go index 219511b8a..7fa17e9bf 100644 --- a/pkg/coredata/cookie_consent_record.go +++ b/pkg/coredata/cookie_consent_record.go @@ -30,14 +30,15 @@ import ( type ( CookieConsentRecord struct { - ID gid.GID `db:"id"` - CookieBannerID gid.GID `db:"cookie_banner_id"` - VisitorID string `db:"visitor_id"` - IPAddress *string `db:"ip_address"` - UserAgent *string `db:"user_agent"` - ConsentData json.RawMessage `db:"consent_data"` - Action CookieConsentAction `db:"action"` - CreatedAt time.Time `db:"created_at"` + ID gid.GID `db:"id"` + CookieBannerID gid.GID `db:"cookie_banner_id"` + CookieBannerVersionID gid.GID `db:"cookie_banner_version_id"` + VisitorID string `db:"visitor_id"` + IPAddress *string `db:"ip_address"` + UserAgent *string `db:"user_agent"` + ConsentData json.RawMessage `db:"consent_data"` + Action CookieConsentAction `db:"action"` + CreatedAt time.Time `db:"created_at"` } CookieConsentRecords []*CookieConsentRecord @@ -84,6 +85,7 @@ func (r *CookieConsentRecords) LoadByCookieBannerID( SELECT id, cookie_banner_id, + cookie_banner_version_id, visitor_id, ip_address, user_agent, @@ -165,6 +167,7 @@ INSERT INTO cookie_consent_records ( id, tenant_id, cookie_banner_id, + cookie_banner_version_id, visitor_id, ip_address, user_agent, @@ -175,6 +178,7 @@ INSERT INTO cookie_consent_records ( @id, @tenant_id, @cookie_banner_id, + @cookie_banner_version_id, @visitor_id, @ip_address, @user_agent, @@ -185,15 +189,16 @@ INSERT INTO cookie_consent_records ( ` args := pgx.StrictNamedArgs{ - "id": r.ID, - "tenant_id": scope.GetTenantID(), - "cookie_banner_id": r.CookieBannerID, - "visitor_id": r.VisitorID, - "ip_address": r.IPAddress, - "user_agent": r.UserAgent, - "consent_data": r.ConsentData, - "action": r.Action, - "created_at": r.CreatedAt, + "id": r.ID, + "tenant_id": scope.GetTenantID(), + "cookie_banner_id": r.CookieBannerID, + "cookie_banner_version_id": r.CookieBannerVersionID, + "visitor_id": r.VisitorID, + "ip_address": r.IPAddress, + "user_agent": r.UserAgent, + "consent_data": r.ConsentData, + "action": r.Action, + "created_at": r.CreatedAt, } _, err := tx.Exec(ctx, q, args) diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index e29f27dcf..7d8949b11 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -101,6 +101,7 @@ const ( CookieBannerEntityType uint16 = 75 CookieCategoryEntityType uint16 = 76 CookieConsentRecordEntityType uint16 = 77 + CookieBannerVersionEntityType uint16 = 78 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -253,6 +254,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &CookieCategory{ID: id}, true case CookieConsentRecordEntityType: return &CookieConsentRecord{ID: id}, true + case CookieBannerVersionEntityType: + return &CookieBannerVersion{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260413T120000Z.sql b/pkg/coredata/migrations/20260413T120000Z.sql new file mode 100644 index 000000000..e2d930ccf --- /dev/null +++ b/pkg/coredata/migrations/20260413T120000Z.sql @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +-- PERFORMANCE OF THIS SOFTWARE. + +-- Replace cookie_banner_state enum: DRAFT/PUBLISHED/DISABLED → ACTIVE/INACTIVE +CREATE TYPE cookie_banner_state_new AS ENUM ('ACTIVE', 'INACTIVE'); + +ALTER TABLE cookie_banners + ALTER COLUMN state TYPE cookie_banner_state_new + USING CASE + WHEN state::text = 'DISABLED' THEN 'INACTIVE'::cookie_banner_state_new + ELSE 'ACTIVE'::cookie_banner_state_new + END; + +DROP TYPE cookie_banner_state; +ALTER TYPE cookie_banner_state_new RENAME TO cookie_banner_state; + +-- Version state for cookie banner configuration snapshots +CREATE TYPE cookie_banner_version_state AS ENUM ('DRAFT', 'PUBLISHED'); + +CREATE TABLE cookie_banner_versions ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + cookie_banner_id TEXT NOT NULL REFERENCES cookie_banners(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + state cookie_banner_version_state NOT NULL, + snapshot JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT cookie_banner_versions_banner_version_key + UNIQUE (cookie_banner_id, version) +); + +-- Add version reference to consent records +ALTER TABLE cookie_consent_records + ADD COLUMN cookie_banner_version_id TEXT NOT NULL REFERENCES cookie_banner_versions(id);