From 8ba3034828937bc31f71f4bd39a3fcfdd368911e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 13 Apr 2026 13:06:08 +0400 Subject: [PATCH] Add version detection and draft provisioning on publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- pkg/cookiebanner/errors.go | 1 + pkg/cookiebanner/service.go | 190 +++++++++++++++++++------- pkg/coredata/cookie_banner_version.go | 47 +++++++ pkg/coredata/cookie_category.go | 2 +- 4 files changed, 192 insertions(+), 48 deletions(-) diff --git a/pkg/cookiebanner/errors.go b/pkg/cookiebanner/errors.go index 910d31fe5..d4864db19 100644 --- a/pkg/cookiebanner/errors.go +++ b/pkg/cookiebanner/errors.go @@ -23,5 +23,6 @@ var ( 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") + ErrNoDraftVersion = errors.New("no draft cookie banner version to publish") ErrCannotDeleteRequiredCategory = errors.New("cannot delete required cookie category") ) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 35873048b..4fe9c0519 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -154,6 +154,81 @@ func (r *CreateCookieConsentRecordRequest) Validate() error { return v.Error() } +func buildSnapshot( + banner *coredata.CookieBanner, + categories coredata.CookieCategories, +) coredata.CookieBannerVersionSnapshot { + 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, + } + } + + return coredata.CookieBannerVersionSnapshot{ + PrivacyPolicyURL: banner.PrivacyPolicyURL, + ConsentExpiryDays: banner.ConsentExpiryDays, + ConsentMode: string(banner.ConsentMode), + Categories: snapshotCategories, + } +} + +func (s *Service) ensureDraftVersion( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + banner *coredata.CookieBanner, + categories coredata.CookieCategories, +) (*coredata.CookieBannerVersion, error) { + snapshot := buildSnapshot(banner, categories) + + var latest coredata.CookieBannerVersion + err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID) + + if err == nil && 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), + 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) CreateCookieBanner( ctx context.Context, scope coredata.Scoper, @@ -318,6 +393,8 @@ func (s *Service) UpdateCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } + consentChanged := req.PrivacyPolicyURL != nil || req.ConsentExpiryDays != nil || req.ConsentMode != nil + if req.Name != nil { banner.Name = *req.Name } @@ -340,6 +417,17 @@ func (s *Service) UpdateCookieBanner( return fmt.Errorf("cannot update cookie banner: %w", err) } + if consentChanged { + var categories coredata.CookieCategories + if err := categories.LoadAllByCookieBannerID(ctx, tx, banner.ID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + } + return nil }, ) @@ -355,63 +443,27 @@ func (s *Service) PublishCookieBannerVersion( scope coredata.Scoper, bannerID gid.GID, ) (*coredata.CookieBannerVersion, error) { - var version *coredata.CookieBannerVersion + 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 err := version.LoadLatestByCookieBannerID(ctx, tx, scope, bannerID); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - return ErrBannerNotFound + return ErrNoDraftVersion } - return fmt.Errorf("cannot load cookie banner: %w", err) + return fmt.Errorf("cannot load latest version: %w", err) } - var categories coredata.CookieCategories - if err := categories.LoadAllPublicByCookieBannerID(ctx, tx, bannerID); err != nil { - return fmt.Errorf("cannot load cookie categories: %w", err) + if version.State != coredata.CookieBannerVersionStateDraft { + return ErrNoDraftVersion } - 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, - } - } + version.State = coredata.CookieBannerVersionStatePublished + version.UpdatedAt = time.Now() - 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) + if err := version.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot publish version: %w", err) } return nil @@ -421,7 +473,7 @@ func (s *Service) PublishCookieBannerVersion( return nil, err } - return version, nil + return &version, nil } func (s *Service) ActivateCookieBanner( @@ -562,6 +614,20 @@ func (s *Service) CreateCookieCategory( return fmt.Errorf("cannot insert cookie category: %w", err) } + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, req.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var categories coredata.CookieCategories + if err := categories.LoadAllByCookieBannerID(ctx, tx, req.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + return nil }, ) @@ -692,6 +758,20 @@ func (s *Service) UpdateCookieCategory( return fmt.Errorf("cannot update cookie category: %w", err) } + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var categories coredata.CookieCategories + if err := categories.LoadAllByCookieBannerID(ctx, tx, category.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + return nil }, ) @@ -722,10 +802,26 @@ func (s *Service) DeleteCookieCategory( return ErrCannotDeleteRequiredCategory } + bannerID := category.CookieBannerID + if err := category.Delete(ctx, tx, scope); err != nil { return fmt.Errorf("cannot delete cookie category: %w", err) } + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var categories coredata.CookieCategories + if err := categories.LoadAllByCookieBannerID(ctx, tx, bannerID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + return nil }, ) diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go index 0b763b6d0..91ebbff34 100644 --- a/pkg/coredata/cookie_banner_version.go +++ b/pkg/coredata/cookie_banner_version.go @@ -275,6 +275,53 @@ LIMIT 1; return nil } +func (v *CookieBannerVersion) LoadLatestByCookieBannerID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + cookieBannerID gid.GID, +) 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 +ORDER BY version DESC +LIMIT 1; +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"cookie_banner_id": cookieBannerID} + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query cookie 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, diff --git a/pkg/coredata/cookie_category.go b/pkg/coredata/cookie_category.go index 314a6549d..c4ff4d1be 100644 --- a/pkg/coredata/cookie_category.go +++ b/pkg/coredata/cookie_category.go @@ -222,7 +222,7 @@ WHERE return count, nil } -func (c *CookieCategories) LoadAllPublicByCookieBannerID( +func (c *CookieCategories) LoadAllByCookieBannerID( ctx context.Context, conn pg.Querier, cookieBannerID gid.GID,