diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx index 244aefedd..83d3a74dc 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx @@ -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(updateCategoryMutation); + const [moveCookie, _isMoving] + = useMutation(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) { diff --git a/pkg/cookiebanner/errors.go b/pkg/cookiebanner/errors.go index 7c4229893..c3abea730 100644 --- a/pkg/cookiebanner/errors.go +++ b/pkg/cookiebanner/errors.go @@ -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") ) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 849f3f861..d0e2b20df 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -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) } } diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index 9652542e4..5f7b851d1 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -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} } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index fc5bec519..89f442d5d 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -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! +}