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, + } +}