Add moveCookieToCategory mutation

Moving a cookie between categories previously required two sequential
updateCookieCategory mutations, which was not atomic and could leave
data in an inconsistent state if the second call failed. This adds a
dedicated moveCookieToCategory mutation that performs both updates in
a single transaction.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-21 10:34:17 +04:00
parent 1574600c72
commit 653b43fc81
5 changed files with 225 additions and 80 deletions

View File

@@ -38,6 +38,7 @@ import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
import type { CategorySectionMoveCookieMutation } from "#/__generated__/core/CategorySectionMoveCookieMutation.graphql";
import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql";
export const categorySectionFragment = graphql`
@@ -98,6 +99,41 @@ const updateCategoryMutation = graphql`
}
`;
const moveCookieMutation = graphql`
mutation CategorySectionMoveCookieMutation(
$input: MoveCookieToCategoryInput!
) {
moveCookieToCategory(input: $input) {
sourceCookieCategory {
id
cookies {
name
duration
description
}
updatedAt
}
targetCookieCategory {
id
cookies {
name
duration
description
}
updatedAt
}
cookieBanner {
id
latestVersion {
id
version
state
}
}
}
}
`;
interface CookieEntry {
name: string;
duration: string;
@@ -116,6 +152,8 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const [updateCategory, isUpdating]
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
const [moveCookie, _isMoving]
= useMutation<CategorySectionMoveCookieMutation>(moveCookieMutation);
const [isEditingCategory, setIsEditingCategory] = useState(false);
const [editName, setEditName] = useState(category.name);
@@ -256,23 +294,13 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const handleMoveCookie = (cookieIndex: number, targetCategoryId: string) => {
const cookie = category.cookies[cookieIndex];
const targetCategory = siblingCategories.find(c => c.id === targetCategoryId);
if (!targetCategory) return;
const targetCookies = [
...targetCategory.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
})),
{ name: cookie.name, duration: cookie.duration, description: cookie.description },
];
updateCategory({
moveCookie({
variables: {
input: {
cookieCategoryId: targetCategoryId,
cookies: targetCookies,
sourceCookieCategoryId: category.id,
targetCookieCategoryId: targetCategoryId,
cookieName: cookie.name,
},
},
onCompleted(_response, errors) {
@@ -284,47 +312,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
});
return;
}
const sourceCookies = category.cookies
.filter((_, i) => i !== cookieIndex)
.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
updateCategory({
variables: {
input: {
cookieCategoryId: category.id,
cookies: sourceCookies,
},
},
onCompleted(_response, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Cookie moved"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to move cookie"),
error as GraphQLError,
),
variant: "error",
});
},
toast({
title: __("Success"),
description: __("Cookie moved"),
variant: "success",
});
},
onError(error) {

View File

@@ -28,4 +28,6 @@ var (
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner")
ErrConsentNotFound = errors.New("consent record not found")
ErrCookieNotFound = errors.New("cookie not found in source category")
ErrCategoriesBannerMismatch = errors.New("source and target categories belong to different banners")
)

View File

@@ -90,6 +90,12 @@ type (
Rank int
}
MoveCookieToCategoryRequest struct {
SourceCookieCategoryID gid.GID
TargetCookieCategoryID gid.GID
CookieName string
}
CreateCookieConsentRecordRequest struct {
CookieBannerID gid.GID
Version int
@@ -183,6 +189,16 @@ func (r *ReorderCookieCategoryRequest) Validate() error {
return v.Error()
}
func (r *MoveCookieToCategoryRequest) Validate() error {
v := validator.New()
v.Check(r.SourceCookieCategoryID, "source_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.TargetCookieCategoryID, "target_cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.CookieName, "cookie_name", validator.Required())
return v.Error()
}
func (r *CreateCookieConsentRecordRequest) Validate() error {
v := validator.New()
@@ -896,6 +912,101 @@ func (s *Service) UpdateCookieCategory(
return &category, nil
}
type MoveCookieToCategoryResult struct {
SourceCategory *coredata.CookieCategory
TargetCategory *coredata.CookieCategory
Banner *coredata.CookieBanner
}
func (s *Service) MoveCookieToCategory(
ctx context.Context,
scope coredata.Scoper,
req MoveCookieToCategoryRequest,
) (*MoveCookieToCategoryResult, error) {
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
var result MoveCookieToCategoryResult
err := s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
var source coredata.CookieCategory
if err := source.LoadByID(ctx, tx, scope, req.SourceCookieCategoryID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrCategoryNotFound
}
return fmt.Errorf("cannot load source cookie category: %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 source.CookieBannerID != target.CookieBannerID {
return ErrCategoriesBannerMismatch
}
cookieIdx := -1
for i, c := range source.Cookies {
if c.Name == req.CookieName {
cookieIdx = i
break
}
}
if cookieIdx == -1 {
return ErrCookieNotFound
}
cookie := source.Cookies[cookieIdx]
source.Cookies = append(source.Cookies[:cookieIdx], source.Cookies[cookieIdx+1:]...)
target.Cookies = append(target.Cookies, cookie)
now := time.Now()
source.UpdatedAt = now
target.UpdatedAt = now
if err := source.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update source cookie category: %w", err)
}
if err := target.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update target cookie category: %w", err)
}
var banner coredata.CookieBanner
if err := banner.LoadByID(ctx, tx, scope, source.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie banner: %w", err)
}
var categories coredata.CookieCategories
if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, source.CookieBannerID); err != nil {
return fmt.Errorf("cannot load cookie categories: %w", err)
}
if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil {
return fmt.Errorf("cannot ensure draft version: %w", err)
}
result.SourceCategory = &source
result.TargetCategory = &target
result.Banner = &banner
return nil
},
)
if err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) ReorderCookieCategory(
ctx context.Context,
scope coredata.Scoper,
@@ -972,32 +1083,13 @@ func (s *Service) DeleteCookieCategory(
if len(category.Cookies) > 0 {
var uncategorised coredata.CookieCategory
err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID)
if errors.Is(err, coredata.ErrResourceNotFound) {
now := time.Now()
uncategorised = coredata.CookieCategory{
ID: gid.New(scope.GetTenantID(), coredata.CookieCategoryEntityType),
OrganizationID: category.OrganizationID,
CookieBannerID: bannerID,
Name: "Uncategorised",
Description: "Cookies that have not been assigned to a category yet.",
Kind: coredata.CookieCategoryKindUncategorised,
Rank: category.Rank + 1,
Cookies: category.Cookies,
CreatedAt: now,
UpdatedAt: now,
}
if err := uncategorised.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot create uncategorised cookie category: %w", err)
}
} else if err != nil {
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
} else {
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...)
uncategorised.UpdatedAt = time.Now()
if err := uncategorised.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
}
}
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...)
uncategorised.UpdatedAt = time.Now()
if err := uncategorised.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
}
}

View File

@@ -512,6 +512,51 @@ func (r *mutationResolver) ReorderCookieCategory(ctx context.Context, input type
}, nil
}
// MoveCookieToCategory is the resolver for the moveCookieToCategory field.
func (r *mutationResolver) MoveCookieToCategory(ctx context.Context, input types.MoveCookieToCategoryInput) (*types.MoveCookieToCategoryPayload, error) {
if err := r.authorize(ctx, input.SourceCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil {
return nil, err
}
if err := r.authorize(ctx, input.TargetCookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(input.SourceCookieCategoryID)
result, err := r.cookieBanner.MoveCookieToCategory(
ctx,
scope,
cookiebanner.MoveCookieToCategoryRequest{
SourceCookieCategoryID: input.SourceCookieCategoryID,
TargetCookieCategoryID: input.TargetCookieCategoryID,
CookieName: input.CookieName,
},
)
if err != nil {
switch {
case errors.Is(err, cookiebanner.ErrCategoryNotFound):
return nil, gqlutils.NotFound(ctx, err)
case errors.Is(err, cookiebanner.ErrCookieNotFound):
return nil, gqlutils.NotFound(ctx, err)
case errors.Is(err, cookiebanner.ErrCategoriesBannerMismatch):
return nil, gqlutils.Invalidf(ctx, "source and target categories must belong to the same banner")
default:
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
}
r.logger.ErrorCtx(ctx, "cannot move cookie to category", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
}
return &types.MoveCookieToCategoryPayload{
SourceCookieCategory: types.NewCookieCategory(result.SourceCategory),
TargetCookieCategory: types.NewCookieCategory(result.TargetCategory),
CookieBanner: types.NewCookieBanner(result.Banner),
}, nil
}
// CookieBanner returns schema.CookieBannerResolver implementation.
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }

View File

@@ -172,6 +172,9 @@ extend type Mutation {
reorderCookieCategory(
input: ReorderCookieCategoryInput!
): ReorderCookieCategoryPayload!
moveCookieToCategory(
input: MoveCookieToCategoryInput!
): MoveCookieToCategoryPayload!
}
input CreateCookieBannerInput {
@@ -238,6 +241,12 @@ input CookieItemInput {
description: String!
}
input MoveCookieToCategoryInput {
sourceCookieCategoryId: ID!
targetCookieCategoryId: ID!
cookieName: String!
}
type CreateCookieBannerPayload {
cookieBannerEdge: CookieBannerEdge!
}
@@ -281,3 +290,9 @@ type DeleteCookieCategoryPayload {
type ReorderCookieCategoryPayload {
cookieBanner: CookieBanner!
}
type MoveCookieToCategoryPayload {
sourceCookieCategory: CookieCategory!
targetCookieCategory: CookieCategory!
cookieBanner: CookieBanner!
}