From 9c402058ec657ffbac733b2504eb26e3ab1ef243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 29 Apr 2026 14:24:28 +0400 Subject: [PATCH] Add GraphQL API and service CRUD for cookie patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CookiePattern type, connection, and mutations to the GraphQL schema with full resolver implementations. Add service methods for pattern CRUD, category movement, listing, and counting. This enables the console to manage cookie patterns instead of individual cookies. Signed-off-by: Émile Ré --- pkg/cookiebanner/errors.go | 3 + pkg/cookiebanner/service.go | 356 ++++++++++++++++++ pkg/probo/actions.go | 7 + .../api/console/v1/cookie_banner_resolvers.go | 273 ++++++++++++++ .../console/v1/graphql/cookie_banner.graphql | 127 +++++++ .../api/console/v1/types/cookie_pattern.go | 81 ++++ 6 files changed, 847 insertions(+) create mode 100644 pkg/server/api/console/v1/types/cookie_pattern.go diff --git a/pkg/cookiebanner/errors.go b/pkg/cookiebanner/errors.go index 177a58736..cea16300e 100644 --- a/pkg/cookiebanner/errors.go +++ b/pkg/cookiebanner/errors.go @@ -34,4 +34,7 @@ var ( ErrCategoriesBannerMismatch = errors.New("source and target categories belong to different banners") ErrSameCategoryMove = errors.New("source and target cookie categories must be different") ErrPostHogConsentKindInvalid = errors.New("PostHog consent can only be enabled on normal categories") + ErrCookiePatternNotFound = errors.New("cookie pattern not found") + ErrPatternAlreadyExists = errors.New("a pattern with this name already exists in this banner") + ErrSamePatternCategoryMove = errors.New("source and target cookie categories must be different") ) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 42b0f63b9..a3da90ae5 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -102,6 +102,32 @@ type ( TargetCookieCategoryID gid.GID } + CreateCookiePatternRequest struct { + CookieCategoryID gid.GID + Pattern string + MatchType coredata.CookiePatternMatchType + DisplayName string + Duration string + Description string + } + + UpdateCookiePatternRequest struct { + CookiePatternID gid.GID + DisplayName *string + Duration *string + Description *string + } + + MoveCookiePatternToCategoryRequest struct { + CookiePatternID gid.GID + TargetCookieCategoryID gid.GID + } + + MoveCookiePatternToCategoryResult struct { + CookiePattern *coredata.CookiePattern + Banner *coredata.CookieBanner + } + CreateCookieConsentRecordRequest struct { CookieBannerID gid.GID Version int @@ -244,6 +270,48 @@ func (r *ReorderCookieCategoryRequest) Validate() error { return v.Error() } +func (r *CreateCookiePatternRequest) Validate() error { + v := validator.New() + + v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) + 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.CookiePatternMatchTypes() + s := make([]string, len(types)) + for i, t := range types { + s[i] = string(t) + } + return s + }(), + )) + v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255)) + v.Check(r.Duration, "duration", validator.Required(), validator.SafeTextNoNewLine(255)) + v.Check(r.Description, "description", validator.SafeText(1000)) + + return v.Error() +} + +func (r *UpdateCookiePatternRequest) Validate() error { + v := validator.New() + + v.Check(r.CookiePatternID, "cookie_pattern_id", validator.Required(), validator.GID(coredata.CookiePatternEntityType)) + v.Check(r.DisplayName, "display_name", validator.SafeTextNoNewLine(255)) + v.Check(r.Duration, "duration", validator.SafeTextNoNewLine(255)) + v.Check(r.Description, "description", validator.SafeText(1000)) + + return v.Error() +} + +func (r *MoveCookiePatternToCategoryRequest) Validate() error { + v := validator.New() + + v.Check(r.CookiePatternID, "cookie_pattern_id", validator.Required(), validator.GID(coredata.CookiePatternEntityType)) + v.Check(r.TargetCookieCategoryID, "target_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) + + return v.Error() +} + func (r *MoveCookieToCategoryRequest) Validate() error { v := validator.New() @@ -1325,6 +1393,294 @@ func (s *Service) GetCookiePattern( return &pattern, nil } +func (s *Service) CreateCookiePattern( + ctx context.Context, + scope coredata.Scoper, + req CreateCookiePatternRequest, +) (*coredata.CookiePattern, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + var pattern *coredata.CookiePattern + + 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.CookiePattern{ + ID: gid.New(scope.GetTenantID(), coredata.CookiePatternEntityType), + OrganizationID: category.OrganizationID, + CookieBannerID: category.CookieBannerID, + CookieCategoryID: category.ID, + Pattern: req.Pattern, + MatchType: req.MatchType, + DisplayName: req.DisplayName, + Duration: req.Duration, + Description: req.Description, + Source: coredata.CookieSourceScript, + 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 cookie pattern: %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 pattern, nil +} + +func (s *Service) UpdateCookiePattern( + ctx context.Context, + scope coredata.Scoper, + req UpdateCookiePatternRequest, +) (*coredata.CookiePattern, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + var pattern coredata.CookiePattern + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := pattern.LoadByID(ctx, tx, scope, req.CookiePatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load cookie pattern: %w", err) + } + + if req.DisplayName != nil { + pattern.DisplayName = *req.DisplayName + } + if req.Duration != nil { + pattern.Duration = *req.Duration + } + if req.Description != nil { + pattern.Description = *req.Description + } + + pattern.UpdatedAt = time.Now() + + if err := pattern.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update cookie pattern: %w", err) + } + + 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) DeleteCookiePattern( + ctx context.Context, + scope coredata.Scoper, + cookiePatternID gid.GID, +) error { + return s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var pattern coredata.CookiePattern + if err := pattern.LoadByID(ctx, tx, scope, cookiePatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load cookie pattern: %w", err) + } + + if err := pattern.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot delete cookie pattern: %w", err) + } + + 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) MoveCookiePatternToCategory( + ctx context.Context, + scope coredata.Scoper, + req MoveCookiePatternToCategoryRequest, +) (*MoveCookiePatternToCategoryResult, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + var result MoveCookiePatternToCategoryResult + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var pattern coredata.CookiePattern + if err := pattern.LoadByID(ctx, tx, scope, req.CookiePatternID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCookiePatternNotFound + } + return fmt.Errorf("cannot load cookie 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 + } + + pattern.CookieCategoryID = target.ID + pattern.UpdatedAt = time.Now() + + if err := pattern.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update cookie 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 _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + + result.CookiePattern = &pattern + result.Banner = &banner + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (s *Service) ListCookiePatternsForCategory( + ctx context.Context, + scope coredata.Scoper, + categoryID gid.GID, + cursor *page.Cursor[coredata.CookiePatternOrderField], +) (coredata.CookiePatterns, error) { + var patterns coredata.CookiePatterns + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := patterns.LoadByCookieCategoryID(ctx, conn, scope, categoryID, cursor); err != nil { + return fmt.Errorf("cannot list cookie patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return patterns, nil +} + +func (s *Service) CountCookiePatternsForCategory( + 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.CookiePatterns + var err error + + count, err = patterns.CountByCookieCategoryID(ctx, conn, scope, categoryID) + if err != nil { + return fmt.Errorf("cannot count cookie patterns: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Service) CountCookiesForPattern( + ctx context.Context, + scope coredata.Scoper, + patternID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var cookies coredata.Cookies + var err error + + count, err = cookies.CountByCookiePatternID(ctx, conn, scope, patternID) + if err != nil { + return fmt.Errorf("cannot count cookies for pattern: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + func (s *Service) UpdateCookie( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/probo/actions.go b/pkg/probo/actions.go index 98e7ab962..3f4961e22 100644 --- a/pkg/probo/actions.go +++ b/pkg/probo/actions.go @@ -410,6 +410,13 @@ const ( ActionCookieUpdate = "core:cookie:update" ActionCookieDelete = "core:cookie:delete" + // CookiePattern actions + ActionCookiePatternGet = "core:cookie-pattern:get" + ActionCookiePatternList = "core:cookie-pattern:list" + ActionCookiePatternCreate = "core:cookie-pattern:create" + ActionCookiePatternUpdate = "core:cookie-pattern:update" + ActionCookiePatternDelete = "core:cookie-pattern:delete" + // CookieConsentRecord actions ActionCookieConsentRecordList = "core:cookie-consent-record:list" ) diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index 33660c71c..1c280b65b 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -322,6 +322,37 @@ func (r *cookieCategoryResolver) Cookies(ctx context.Context, obj *types.CookieC return types.NewCookieConnection(p, r, obj.ID, obj.ID), nil } +// CookiePatterns is the resolver for the cookiePatterns field. +func (r *cookieCategoryResolver) CookiePatterns(ctx context.Context, obj *types.CookieCategory, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookiePatternOrderBy) (*types.CookiePatternConnection, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionCookiePatternList); err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.CookiePatternOrderField]{ + Field: coredata.CookiePatternOrderFieldCreatedAt, + Direction: page.OrderDirectionAsc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.CookiePatternOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + scope := coredata.NewScopeFromObjectID(obj.ID) + + patterns, err := r.cookieBanner.ListCookiePatternsForCategory(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list cookie patterns", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + p := page.NewPage(patterns, cursor) + + return types.NewCookiePatternConnection(p, r, obj.ID), nil +} + // Permission is the resolver for the permission field. func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -361,6 +392,65 @@ func (r *cookieConnectionResolver) TotalCount(ctx context.Context, obj *types.Co return count, nil } +// CookieCategory is the resolver for the cookieCategory field. +func (r *cookiePatternResolver) CookieCategory(ctx context.Context, obj *types.CookiePattern) (*types.CookieCategory, error) { + if err := r.authorize(ctx, obj.CookieCategory.ID, probo.ActionCookieCategoryGet); err != nil { + return nil, err + } + + loaders := dataloader.FromContext(ctx) + + category, err := loaders.CookieCategory.Load(ctx, obj.CookieCategory.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) { + return nil, nil + } + r.logger.ErrorCtx(ctx, "cannot get cookie category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCookieCategory(category), nil +} + +// CookieCount is the resolver for the cookieCount field. +func (r *cookiePatternResolver) CookieCount(ctx context.Context, obj *types.CookiePattern) (int, error) { + if err := r.authorize(ctx, obj.ID, probo.ActionCookiePatternGet); err != nil { + return 0, err + } + + scope := coredata.NewScopeFromObjectID(obj.ID) + + count, err := r.cookieBanner.CountCookiesForPattern(ctx, scope, obj.ID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count cookies for pattern", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + +// Permission is the resolver for the permission field. +func (r *cookiePatternResolver) Permission(ctx context.Context, obj *types.CookiePattern, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *cookiePatternConnectionResolver) TotalCount(ctx context.Context, obj *types.CookiePatternConnection) (int, error) { + if err := r.authorize(ctx, obj.ParentID, probo.ActionCookiePatternList); err != nil { + return 0, err + } + + scope := coredata.NewScopeFromObjectID(obj.ParentID) + + count, err := r.cookieBanner.CountCookiePatternsForCategory(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count cookie patterns", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + // CreateCookieBanner is the resolver for the createCookieBanner field. func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) { if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil { @@ -902,6 +992,179 @@ func (r *mutationResolver) DeleteCookie(ctx context.Context, input types.DeleteC }, nil } +// CreateCookiePattern is the resolver for the createCookiePattern field. +func (r *mutationResolver) CreateCookiePattern(ctx context.Context, input types.CreateCookiePatternInput) (*types.CreateCookiePatternPayload, error) { + if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionCookiePatternCreate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookieCategoryID) + + pattern, err := r.cookieBanner.CreateCookiePattern( + ctx, + scope, + cookiebanner.CreateCookiePatternRequest{ + CookieCategoryID: input.CookieCategoryID, + Pattern: input.Pattern, + MatchType: input.MatchType, + DisplayName: input.DisplayName, + Duration: input.Duration, + Description: input.Description, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrPatternAlreadyExists) { + return nil, gqlutils.Conflict(ctx, err) + } + if errors.Is(err, cookiebanner.ErrCategoryNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot create cookie pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(pattern.CookieBannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, pattern.CookieBannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateCookiePatternPayload{ + CookiePatternEdge: types.NewCookiePatternEdge(pattern, coredata.CookiePatternOrderFieldCreatedAt), + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// UpdateCookiePattern is the resolver for the updateCookiePattern field. +func (r *mutationResolver) UpdateCookiePattern(ctx context.Context, input types.UpdateCookiePatternInput) (*types.UpdateCookiePatternPayload, error) { + if err := r.authorize(ctx, input.CookiePatternID, probo.ActionCookiePatternUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookiePatternID) + + pattern, err := r.cookieBanner.UpdateCookiePattern( + ctx, + scope, + cookiebanner.UpdateCookiePatternRequest{ + CookiePatternID: input.CookiePatternID, + DisplayName: input.DisplayName, + Duration: input.Duration, + Description: input.Description, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrCookiePatternNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot update cookie pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(pattern.CookieBannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, pattern.CookieBannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateCookiePatternPayload{ + CookiePattern: types.NewCookiePattern(pattern), + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// DeleteCookiePattern is the resolver for the deleteCookiePattern field. +func (r *mutationResolver) DeleteCookiePattern(ctx context.Context, input types.DeleteCookiePatternInput) (*types.DeleteCookiePatternPayload, error) { + if err := r.authorize(ctx, input.CookiePatternID, probo.ActionCookiePatternDelete); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookiePatternID) + + pattern, err := r.cookieBanner.GetCookiePattern(ctx, scope, input.CookiePatternID) + if err != nil { + if errors.Is(err, cookiebanner.ErrCookieNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot get cookie pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerID := pattern.CookieBannerID + + err = r.cookieBanner.DeleteCookiePattern(ctx, scope, input.CookiePatternID) + if err != nil { + if errors.Is(err, cookiebanner.ErrCookiePatternNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot delete cookie pattern", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + bannerScope := coredata.NewScopeFromObjectID(bannerID) + banner, err := r.cookieBanner.GetCookieBanner(ctx, bannerScope, bannerID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteCookiePatternPayload{ + DeletedCookiePatternID: input.CookiePatternID, + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + +// MoveCookiePatternToCategory is the resolver for the moveCookiePatternToCategory field. +func (r *mutationResolver) MoveCookiePatternToCategory(ctx context.Context, input types.MoveCookiePatternToCategoryInput) (*types.MoveCookiePatternToCategoryPayload, error) { + if err := r.authorize(ctx, input.CookiePatternID, probo.ActionCookiePatternUpdate); err != nil { + return nil, err + } + + if err := r.authorize(ctx, input.TargetCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookiePatternID) + + result, err := r.cookieBanner.MoveCookiePatternToCategory( + ctx, + scope, + cookiebanner.MoveCookiePatternToCategoryRequest{ + CookiePatternID: input.CookiePatternID, + TargetCookieCategoryID: input.TargetCookieCategoryID, + }, + ) + if err != nil { + switch { + case errors.Is(err, cookiebanner.ErrCategoryNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrCookiePatternNotFound): + return nil, gqlutils.NotFound(ctx, err) + case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch): + return nil, gqlutils.NotFoundf(ctx, "cookie pattern or target category not found") + default: + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot move cookie pattern to category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + } + + return &types.MoveCookiePatternToCategoryPayload{ + CookiePattern: types.NewCookiePattern(result.CookiePattern), + CookieBanner: types.NewCookieBanner(result.Banner), + }, nil +} + // UpsertCookieBannerTranslation is the resolver for the upsertCookieBannerTranslation field. func (r *mutationResolver) UpsertCookieBannerTranslation(ctx context.Context, input types.UpsertCookieBannerTranslationInput) (*types.UpsertCookieBannerTranslationPayload, error) { if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerUpdate); err != nil { @@ -971,6 +1234,14 @@ func (r *Resolver) CookieConnection() schema.CookieConnectionResolver { return &cookieConnectionResolver{r} } +// CookiePattern returns schema.CookiePatternResolver implementation. +func (r *Resolver) CookiePattern() schema.CookiePatternResolver { return &cookiePatternResolver{r} } + +// CookiePatternConnection returns schema.CookiePatternConnectionResolver implementation. +func (r *Resolver) CookiePatternConnection() schema.CookiePatternConnectionResolver { + return &cookiePatternConnectionResolver{r} +} + type cookieResolver struct{ *Resolver } type cookieBannerResolver struct{ *Resolver } type cookieBannerConnectionResolver struct{ *Resolver } @@ -978,3 +1249,5 @@ type cookieBannerVersionResolver struct{ *Resolver } type cookieCategoryResolver struct{ *Resolver } type cookieCategoryConnectionResolver struct{ *Resolver } type cookieConnectionResolver struct{ *Resolver } +type cookiePatternResolver struct{ *Resolver } +type cookiePatternConnectionResolver struct{ *Resolver } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 681341680..3a1f9da86 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -156,12 +156,52 @@ type CookieCategory implements Node { orderBy: CookieOrder ): CookieConnection @goField(forceResolver: true) + cookiePatterns( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: CookiePatternOrder + ): CookiePatternConnection @goField(forceResolver: true) + createdAt: Datetime! updatedAt: Datetime! permission(action: String!): Boolean! @goField(forceResolver: true) } +enum CookiePatternMatchType + @goModel( + model: "go.probo.inc/probo/pkg/coredata.CookiePatternMatchType" + ) { + EXACT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternMatchTypeExact" + ) + PREFIX + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternMatchTypePrefix" + ) +} + +enum CookiePatternOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CookiePatternOrderFieldCreatedAt" + ) +} + +input CookiePatternOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookiePatternOrderBy" + ) { + direction: OrderDirection! + field: CookiePatternOrderField! +} + enum CookieOrderField @goModel( model: "go.probo.inc/probo/pkg/coredata.CookieOrderField" @@ -207,6 +247,36 @@ type CookieEdge { node: Cookie! } +type CookiePattern implements Node { + id: ID! + cookieCategory: CookieCategory @goField(forceResolver: true) + pattern: String! + matchType: CookiePatternMatchType! + displayName: String! + duration: String! + description: String! + source: CookieSource! + cookieCount: Int! @goField(forceResolver: true) + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type CookiePatternConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookiePatternConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [CookiePatternEdge!]! + pageInfo: PageInfo! +} + +type CookiePatternEdge { + cursor: CursorKey! + node: CookiePattern! +} + type CookieBannerVersion implements Node { id: ID! version: Int! @@ -295,6 +365,18 @@ extend type Mutation { createCookie(input: CreateCookieInput!): CreateCookiePayload! updateCookie(input: UpdateCookieInput!): UpdateCookiePayload! deleteCookie(input: DeleteCookieInput!): DeleteCookiePayload! + createCookiePattern( + input: CreateCookiePatternInput! + ): CreateCookiePatternPayload! + updateCookiePattern( + input: UpdateCookiePatternInput! + ): UpdateCookiePatternPayload! + deleteCookiePattern( + input: DeleteCookiePatternInput! + ): DeleteCookiePatternPayload! + moveCookiePatternToCategory( + input: MoveCookiePatternToCategoryInput! + ): MoveCookiePatternToCategoryPayload! upsertCookieBannerTranslation( input: UpsertCookieBannerTranslationInput! ): UpsertCookieBannerTranslationPayload! @@ -449,6 +531,51 @@ type DeleteCookiePayload { cookieBanner: CookieBanner! } +input CreateCookiePatternInput { + cookieCategoryId: ID! + pattern: String! + matchType: CookiePatternMatchType! + displayName: String! + duration: String! + description: String! +} + +input UpdateCookiePatternInput { + cookiePatternId: ID! + displayName: String + duration: String + description: String +} + +input DeleteCookiePatternInput { + cookiePatternId: ID! +} + +input MoveCookiePatternToCategoryInput { + cookiePatternId: ID! + targetCookieCategoryId: ID! +} + +type CreateCookiePatternPayload { + cookiePatternEdge: CookiePatternEdge! + cookieBanner: CookieBanner! +} + +type UpdateCookiePatternPayload { + cookiePattern: CookiePattern! + cookieBanner: CookieBanner! +} + +type DeleteCookiePatternPayload { + deletedCookiePatternId: ID! + cookieBanner: CookieBanner! +} + +type MoveCookiePatternToCategoryPayload { + cookiePattern: CookiePattern! + cookieBanner: CookieBanner! +} + input UpsertCookieBannerTranslationInput { cookieBannerId: ID! language: String! diff --git a/pkg/server/api/console/v1/types/cookie_pattern.go b/pkg/server/api/console/v1/types/cookie_pattern.go new file mode 100644 index 000000000..109d648b1 --- /dev/null +++ b/pkg/server/api/console/v1/types/cookie_pattern.go @@ -0,0 +1,81 @@ +// 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 types + +import ( + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + CookiePatternOrderBy OrderBy[coredata.CookiePatternOrderField] + + CookiePatternConnection struct { + TotalCount int + Edges []*CookiePatternEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + } +) + +func NewCookiePatternConnection( + p *page.Page[*coredata.CookiePattern, coredata.CookiePatternOrderField], + parentType any, + parentID gid.GID, +) *CookiePatternConnection { + edges := make([]*CookiePatternEdge, len(p.Data)) + + for i := range edges { + edges[i] = NewCookiePatternEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &CookiePatternConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + + Resolver: parentType, + ParentID: parentID, + } +} + +func NewCookiePatternEdge(cp *coredata.CookiePattern, orderBy coredata.CookiePatternOrderField) *CookiePatternEdge { + return &CookiePatternEdge{ + Cursor: cp.CursorKey(orderBy), + Node: NewCookiePattern(cp), + } +} + +func NewCookiePattern(cp *coredata.CookiePattern) *CookiePattern { + return &CookiePattern{ + ID: cp.ID, + CookieCategory: &CookieCategory{ + ID: cp.CookieCategoryID, + CookieBanner: &CookieBanner{ + ID: cp.CookieBannerID, + }, + }, + Pattern: cp.Pattern, + MatchType: cp.MatchType, + DisplayName: cp.DisplayName, + Duration: cp.Duration, + Description: cp.Description, + Source: cp.Source, + CreatedAt: cp.CreatedAt, + UpdatedAt: cp.UpdatedAt, + } +}