Add regenerateCookieBannerTrackerPolicy mutation

Expose a non-destructive re-trigger that re-arms tracker policy
generation for a banner that already has a published version, so the
tracker-policy worker regenerates the document after iterating on the
generator. RegenerateTrackerPolicy returns a conflict when nothing has
been published yet.

Wire it across all API surfaces per the api-surface rule: the console
GraphQL mutation and resolver, the MCP tool, the prb cookie-banner
regenerate-policy command, and the n8n operation, with console e2e
coverage for the success and no-published-version paths.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-03 13:37:59 +02:00
parent c763f83b13
commit e6866f88a0
10 changed files with 354 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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")
})
}

View File

@@ -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,
};

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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<INodeExecutionData> {
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 },
};
}

View File

@@ -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))

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 <id>",
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
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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!

View File

@@ -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 {

View File

@@ -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: