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:
@@ -38,6 +38,7 @@ import { useFragment, useMutation } from "react-relay";
|
|||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
|
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";
|
import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql";
|
||||||
|
|
||||||
export const categorySectionFragment = 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 {
|
interface CookieEntry {
|
||||||
name: string;
|
name: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
@@ -116,6 +152,8 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
|
|
||||||
const [updateCategory, isUpdating]
|
const [updateCategory, isUpdating]
|
||||||
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
|
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
|
||||||
|
const [moveCookie, _isMoving]
|
||||||
|
= useMutation<CategorySectionMoveCookieMutation>(moveCookieMutation);
|
||||||
|
|
||||||
const [isEditingCategory, setIsEditingCategory] = useState(false);
|
const [isEditingCategory, setIsEditingCategory] = useState(false);
|
||||||
const [editName, setEditName] = useState(category.name);
|
const [editName, setEditName] = useState(category.name);
|
||||||
@@ -256,23 +294,13 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
|
|
||||||
const handleMoveCookie = (cookieIndex: number, targetCategoryId: string) => {
|
const handleMoveCookie = (cookieIndex: number, targetCategoryId: string) => {
|
||||||
const cookie = category.cookies[cookieIndex];
|
const cookie = category.cookies[cookieIndex];
|
||||||
const targetCategory = siblingCategories.find(c => c.id === targetCategoryId);
|
|
||||||
if (!targetCategory) return;
|
|
||||||
|
|
||||||
const targetCookies = [
|
moveCookie({
|
||||||
...targetCategory.cookies.map(c => ({
|
|
||||||
name: c.name,
|
|
||||||
duration: c.duration,
|
|
||||||
description: c.description,
|
|
||||||
})),
|
|
||||||
{ name: cookie.name, duration: cookie.duration, description: cookie.description },
|
|
||||||
];
|
|
||||||
|
|
||||||
updateCategory({
|
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
cookieCategoryId: targetCategoryId,
|
sourceCookieCategoryId: category.id,
|
||||||
cookies: targetCookies,
|
targetCookieCategoryId: targetCategoryId,
|
||||||
|
cookieName: cookie.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onCompleted(_response, errors) {
|
onCompleted(_response, errors) {
|
||||||
@@ -284,47 +312,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
toast({
|
||||||
const sourceCookies = category.cookies
|
title: __("Success"),
|
||||||
.filter((_, i) => i !== cookieIndex)
|
description: __("Cookie moved"),
|
||||||
.map(c => ({
|
variant: "success",
|
||||||
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",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
|
|||||||
@@ -28,4 +28,6 @@ var (
|
|||||||
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
|
ErrCannotDeleteSystemCategory = errors.New("cannot delete system cookie category")
|
||||||
ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner")
|
ErrOriginAlreadyInUse = errors.New("origin is already used by another active cookie banner")
|
||||||
ErrConsentNotFound = errors.New("consent record not found")
|
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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ type (
|
|||||||
Rank int
|
Rank int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MoveCookieToCategoryRequest struct {
|
||||||
|
SourceCookieCategoryID gid.GID
|
||||||
|
TargetCookieCategoryID gid.GID
|
||||||
|
CookieName string
|
||||||
|
}
|
||||||
|
|
||||||
CreateCookieConsentRecordRequest struct {
|
CreateCookieConsentRecordRequest struct {
|
||||||
CookieBannerID gid.GID
|
CookieBannerID gid.GID
|
||||||
Version int
|
Version int
|
||||||
@@ -183,6 +189,16 @@ func (r *ReorderCookieCategoryRequest) Validate() error {
|
|||||||
return v.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 {
|
func (r *CreateCookieConsentRecordRequest) Validate() error {
|
||||||
v := validator.New()
|
v := validator.New()
|
||||||
|
|
||||||
@@ -896,6 +912,101 @@ func (s *Service) UpdateCookieCategory(
|
|||||||
return &category, nil
|
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(
|
func (s *Service) ReorderCookieCategory(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
@@ -972,32 +1083,13 @@ func (s *Service) DeleteCookieCategory(
|
|||||||
|
|
||||||
if len(category.Cookies) > 0 {
|
if len(category.Cookies) > 0 {
|
||||||
var uncategorised coredata.CookieCategory
|
var uncategorised coredata.CookieCategory
|
||||||
err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID)
|
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
|
||||||
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 {
|
|
||||||
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
|
return fmt.Errorf("cannot load uncategorised cookie category: %w", err)
|
||||||
} else {
|
}
|
||||||
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...)
|
uncategorised.Cookies = append(uncategorised.Cookies, category.Cookies...)
|
||||||
uncategorised.UpdatedAt = time.Now()
|
uncategorised.UpdatedAt = time.Now()
|
||||||
if err := uncategorised.Update(ctx, tx, scope); err != nil {
|
if err := uncategorised.Update(ctx, tx, scope); err != nil {
|
||||||
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
|
return fmt.Errorf("cannot update uncategorised cookie category: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,51 @@ func (r *mutationResolver) ReorderCookieCategory(ctx context.Context, input type
|
|||||||
}, nil
|
}, 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.
|
// CookieBanner returns schema.CookieBannerResolver implementation.
|
||||||
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }
|
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ extend type Mutation {
|
|||||||
reorderCookieCategory(
|
reorderCookieCategory(
|
||||||
input: ReorderCookieCategoryInput!
|
input: ReorderCookieCategoryInput!
|
||||||
): ReorderCookieCategoryPayload!
|
): ReorderCookieCategoryPayload!
|
||||||
|
moveCookieToCategory(
|
||||||
|
input: MoveCookieToCategoryInput!
|
||||||
|
): MoveCookieToCategoryPayload!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateCookieBannerInput {
|
input CreateCookieBannerInput {
|
||||||
@@ -238,6 +241,12 @@ input CookieItemInput {
|
|||||||
description: String!
|
description: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input MoveCookieToCategoryInput {
|
||||||
|
sourceCookieCategoryId: ID!
|
||||||
|
targetCookieCategoryId: ID!
|
||||||
|
cookieName: String!
|
||||||
|
}
|
||||||
|
|
||||||
type CreateCookieBannerPayload {
|
type CreateCookieBannerPayload {
|
||||||
cookieBannerEdge: CookieBannerEdge!
|
cookieBannerEdge: CookieBannerEdge!
|
||||||
}
|
}
|
||||||
@@ -281,3 +290,9 @@ type DeleteCookieCategoryPayload {
|
|||||||
type ReorderCookieCategoryPayload {
|
type ReorderCookieCategoryPayload {
|
||||||
cookieBanner: CookieBanner!
|
cookieBanner: CookieBanner!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MoveCookieToCategoryPayload {
|
||||||
|
sourceCookieCategory: CookieCategory!
|
||||||
|
targetCookieCategory: CookieCategory!
|
||||||
|
cookieBanner: CookieBanner!
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user