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

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