diff --git a/e2e/console/cookie_banner_tracker_policy_test.go b/e2e/console/cookie_banner_tracker_policy_test.go new file mode 100644 index 000000000..b048b86b8 --- /dev/null +++ b/e2e/console/cookie_banner_tracker_policy_test.go @@ -0,0 +1,73 @@ +// 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 console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +const regeneratePolicyMutation = ` + mutation RegenerateCookieBannerTrackerPolicy($input: RegenerateCookieBannerTrackerPolicyInput!) { + regenerateCookieBannerTrackerPolicy(input: $input) { + cookieBanner { id } + } + } +` + +func TestRegenerateCookieBannerTrackerPolicy(t *testing.T) { + t.Parallel() + + t.Run("succeeds for a published banner", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + published := publishBanner(t, owner, bannerID) + require.Equal(t, "PUBLISHED", published.State) + + var result struct { + RegenerateCookieBannerTrackerPolicy struct { + CookieBanner struct { + ID string `json:"id"` + } `json:"cookieBanner"` + } `json:"regenerateCookieBannerTrackerPolicy"` + } + + err := owner.Execute(regeneratePolicyMutation, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result) + require.NoError(t, err) + assert.Equal(t, bannerID, result.RegenerateCookieBannerTrackerPolicy.CookieBanner.ID) + }) + + t.Run("conflicts when the banner has no published version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + var result struct{} + + err := owner.Execute(regeneratePolicyMutation, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result) + require.Error(t, err, "regenerating without a published version should fail") + }) +} diff --git a/packages/n8n-node/nodes/Probo/actions/cookieBanner/index.ts b/packages/n8n-node/nodes/Probo/actions/cookieBanner/index.ts index 72795f317..9e5a1d701 100644 --- a/packages/n8n-node/nodes/Probo/actions/cookieBanner/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/cookieBanner/index.ts @@ -21,6 +21,7 @@ import * as deleteOp from './delete.operation'; import * as activateOp from './activate.operation'; import * as deactivateOp from './deactivate.operation'; import * as publishOp from './publish.operation'; +import * as regeneratePolicyOp from './regeneratePolicy.operation'; import * as translateOp from './translate.operation'; export const description: INodeProperties[] = [ @@ -77,6 +78,12 @@ export const description: INodeProperties[] = [ description: 'Publish a new cookie banner version', action: 'Publish a cookie banner version', }, + { + name: 'Regenerate Policy', + value: 'regeneratePolicy', + description: 'Re-arm tracker policy generation for a published cookie banner', + action: 'Regenerate a cookie banner tracker policy', + }, { name: 'Translate', value: 'translate', @@ -100,6 +107,7 @@ export const description: INodeProperties[] = [ ...activateOp.description, ...deactivateOp.description, ...publishOp.description, + ...regeneratePolicyOp.description, ...translateOp.description, ]; @@ -112,5 +120,6 @@ export { activateOp as activate, deactivateOp as deactivate, publishOp as publish, + regeneratePolicyOp as regeneratePolicy, translateOp as translate, }; diff --git a/packages/n8n-node/nodes/Probo/actions/cookieBanner/regeneratePolicy.operation.ts b/packages/n8n-node/nodes/Probo/actions/cookieBanner/regeneratePolicy.operation.ts new file mode 100644 index 000000000..83b9a15ca --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/cookieBanner/regeneratePolicy.operation.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2025-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. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Cookie Banner ID', + name: 'cookieBannerId', + type: 'string', + displayOptions: { + show: { + resource: ['cookieBanner'], + operation: ['regeneratePolicy'], + }, + }, + default: '', + description: 'The ID of the cookie banner to regenerate the tracker policy for', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const cookieBannerId = this.getNodeParameter('cookieBannerId', itemIndex) as string; + + const query = ` + mutation RegenerateCookieBannerTrackerPolicy($input: RegenerateCookieBannerTrackerPolicyInput!) { + regenerateCookieBannerTrackerPolicy(input: $input) { + cookieBanner { + id + name + origin + state + createdAt + updatedAt + } + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { input: { cookieBannerId } }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/cmd/cookie-banner/cookie_banner.go b/pkg/cmd/cookie-banner/cookie_banner.go index 0545c9d1f..8505a9633 100644 --- a/pkg/cmd/cookie-banner/cookie_banner.go +++ b/pkg/cmd/cookie-banner/cookie_banner.go @@ -24,6 +24,7 @@ import ( "go.probo.inc/probo/pkg/cmd/cookie-banner/latestversion" "go.probo.inc/probo/pkg/cmd/cookie-banner/list" "go.probo.inc/probo/pkg/cmd/cookie-banner/publish" + regeneratepolicy "go.probo.inc/probo/pkg/cmd/cookie-banner/regenerate-policy" "go.probo.inc/probo/pkg/cmd/cookie-banner/translate" "go.probo.inc/probo/pkg/cmd/cookie-banner/update" "go.probo.inc/probo/pkg/cmd/cookie-banner/view" @@ -43,6 +44,7 @@ func NewCmdCookieBanner(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(activate.NewCmdActivate(f)) cmd.AddCommand(deactivate.NewCmdDeactivate(f)) cmd.AddCommand(publish.NewCmdPublish(f)) + cmd.AddCommand(regeneratepolicy.NewCmdRegeneratePolicy(f)) cmd.AddCommand(translate.NewCmdTranslate(f)) cmd.AddCommand(latestversion.NewCmdLatestVersion(f)) diff --git a/pkg/cmd/cookie-banner/regenerate-policy/regenerate_policy.go b/pkg/cmd/cookie-banner/regenerate-policy/regenerate_policy.go new file mode 100644 index 000000000..a3b2d7e70 --- /dev/null +++ b/pkg/cmd/cookie-banner/regenerate-policy/regenerate_policy.go @@ -0,0 +1,87 @@ +// 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 regeneratepolicy + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const regenerateMutation = ` +mutation($input: RegenerateCookieBannerTrackerPolicyInput!) { + regenerateCookieBannerTrackerPolicy(input: $input) { + cookieBanner { + id + name + } + } +} +` + +func NewCmdRegeneratePolicy(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "regenerate-policy ", + Short: "Re-arm tracker policy generation for a published cookie banner", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + input := map[string]any{"cookieBannerId": args[0]} + + data, err := client.Do(regenerateMutation, map[string]any{"input": input}) + if err != nil { + return err + } + + var resp struct { + RegenerateCookieBannerTrackerPolicy struct { + CookieBanner struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"cookieBanner"` + } `json:"regenerateCookieBannerTrackerPolicy"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return err + } + + _, _ = fmt.Fprintf(f.IOStreams.Out, "Re-armed tracker policy generation for cookie banner %s\n", args[0]) + + return nil + }, + } + + return cmd +} diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 6f7af3a23..85a32cad9 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -979,6 +979,51 @@ func (s *Service) PublishCookieBannerVersion( return &version, nil } +// RegenerateTrackerPolicy re-arms tracker policy generation for a banner +// that already has a published version, so the tracker-policy worker +// regenerates the policy document (e.g. after iterating on the generator). +// It returns ErrNoPublishedVersion when nothing has been published yet. +func (s *Service) RegenerateTrackerPolicy( + ctx context.Context, + scope coredata.Scoper, + bannerID gid.GID, +) (*coredata.CookieBanner, error) { + var banner coredata.CookieBanner + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := banner.LoadByID(ctx, tx, scope, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrBannerNotFound + } + + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var version coredata.CookieBannerVersion + if err := version.LoadLatestPublishedByCookieBannerID(ctx, tx, scope, bannerID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrNoPublishedVersion + } + + return fmt.Errorf("cannot load latest published version: %w", err) + } + + if err := banner.SetPolicyGenerationRequested(ctx, tx); err != nil { + return fmt.Errorf("cannot request tracker policy generation: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &banner, nil +} + func (s *Service) ActivateCookieBanner( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index d8fef818c..3eb06a62b 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -777,6 +777,33 @@ func (r *mutationResolver) PublishCookieBannerVersion(ctx context.Context, input }, nil } +// RegenerateCookieBannerTrackerPolicy is the resolver for the regenerateCookieBannerTrackerPolicy field. +func (r *mutationResolver) RegenerateCookieBannerTrackerPolicy(ctx context.Context, input types.RegenerateCookieBannerTrackerPolicyInput) (*types.RegenerateCookieBannerTrackerPolicyPayload, error) { + scope, err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerUpdate) + if err != nil { + return nil, err + } + + banner, err := r.cookieBanner.RegenerateTrackerPolicy(ctx, scope, input.CookieBannerID) + if err != nil { + if errors.Is(err, cookiebanner.ErrBannerNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + + if errors.Is(err, cookiebanner.ErrNoPublishedVersion) { + return nil, gqlutils.Conflict(ctx, err) + } + + r.logger.ErrorCtx(ctx, "cannot regenerate cookie banner tracker policy", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.RegenerateCookieBannerTrackerPolicyPayload{ + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + // CreateCookieCategory is the resolver for the createCookieCategory field. func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types.CreateCookieCategoryInput) (*types.CreateCookieCategoryPayload, error) { scope, err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieCategoryCreate) diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index 2bca80a12..18b9cb4bf 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -607,6 +607,9 @@ extend type Mutation { publishCookieBannerVersion( input: PublishCookieBannerVersionInput! ): PublishCookieBannerVersionPayload! + regenerateCookieBannerTrackerPolicy( + input: RegenerateCookieBannerTrackerPolicyInput! + ): RegenerateCookieBannerTrackerPolicyPayload! createCookieCategory( input: CreateCookieCategoryInput! ): CreateCookieCategoryPayload! @@ -682,6 +685,10 @@ input PublishCookieBannerVersionInput { cookieBannerId: ID! } +input RegenerateCookieBannerTrackerPolicyInput { + cookieBannerId: ID! +} + input CreateCookieCategoryInput { cookieBannerId: ID! name: String! @@ -733,6 +740,10 @@ type PublishCookieBannerVersionPayload { cookieBanner: CookieBanner! } +type RegenerateCookieBannerTrackerPolicyPayload { + cookieBanner: CookieBanner! +} + type CreateCookieCategoryPayload { cookieCategoryEdge: CookieCategoryEdge! cookieBanner: CookieBanner! diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 52624fae9..c779105c2 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -5825,6 +5825,20 @@ func (r *Resolver) PublishCookieBannerVersionTool(ctx context.Context, req *mcp. return nil, types.PublishCookieBannerVersionOutput{CookieBannerVersion: types.NewCookieBannerVersion(version)}, nil } +func (r *Resolver) RegenerateCookieBannerTrackerPolicyTool(ctx context.Context, req *mcp.CallToolRequest, input *types.RegenerateCookieBannerTrackerPolicyInput) (*mcp.CallToolResult, types.RegenerateCookieBannerTrackerPolicyOutput, error) { + scope, err := r.Authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerUpdate) + if err != nil { + return nil, types.RegenerateCookieBannerTrackerPolicyOutput{}, err + } + + banner, err := r.cookieBanner.RegenerateTrackerPolicy(ctx, scope, input.CookieBannerID) + if err != nil { + return nil, types.RegenerateCookieBannerTrackerPolicyOutput{}, fmt.Errorf("cannot regenerate cookie banner tracker policy: %w", err) + } + + return nil, types.RegenerateCookieBannerTrackerPolicyOutput{CookieBanner: types.NewCookieBanner(banner)}, nil +} + func (r *Resolver) ListCookieBannerVersionsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListCookieBannerVersionsInput) (*mcp.CallToolResult, types.ListCookieBannerVersionsOutput, error) { scope, err := r.Authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerVersionList) if err != nil { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 3abc9491b..5d10d0187 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -10358,6 +10358,22 @@ components: cookie_banner_version: $ref: "#/components/schemas/CookieBannerVersion" + RegenerateCookieBannerTrackerPolicyInput: + type: object + required: + - cookie_banner_id + properties: + cookie_banner_id: + $ref: "#/components/schemas/GID" + + RegenerateCookieBannerTrackerPolicyOutput: + type: object + required: + - cookie_banner + properties: + cookie_banner: + $ref: "#/components/schemas/CookieBanner" + ListCookieBannerVersionsInput: type: object required: @@ -13944,6 +13960,14 @@ tools: $ref: "#/components/schemas/PublishCookieBannerVersionInput" outputSchema: $ref: "#/components/schemas/PublishCookieBannerVersionOutput" + - name: regenerateCookieBannerTrackerPolicy + description: Re-arm tracker policy generation for a cookie banner that already has a published version. Returns immediately; the policy document is regenerated in the background. + hints: + readonly: false + inputSchema: + $ref: "#/components/schemas/RegenerateCookieBannerTrackerPolicyInput" + outputSchema: + $ref: "#/components/schemas/RegenerateCookieBannerTrackerPolicyOutput" - name: listCookieBannerVersions description: List all versions for a cookie banner hints: