diff --git a/e2e/console/access_review_test.go b/e2e/console/access_review_test.go index 529081748..918f930c3 100644 --- a/e2e/console/access_review_test.go +++ b/e2e/console/access_review_test.go @@ -396,6 +396,59 @@ func TestAccessReviewCampaign_Update(t *testing.T) { assert.Equal(t, "Renamed Campaign", result.UpdateAccessReviewCampaign.AccessReviewCampaign.Name) } +func TestAccessReviewCampaign_UpdateSources(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + source1ID := factory.NewAccessReviewSource(owner, orgID). + WithName("Slack Source"). + Create() + source2ID := factory.NewAccessReviewSource(owner, orgID). + WithName("GitHub Source"). + Create() + campaignID := factory.NewAccessReviewCampaign(owner, orgID). + WithName("Campaign Sources Update"). + WithAccessReviewSourceIDs([]string{source1ID}). + Create() + + const query = ` + mutation($input: UpdateAccessReviewCampaignInput!) { + updateAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + sources { + sourceId + } + } + } + } + ` + + var result struct { + UpdateAccessReviewCampaign struct { + AccessReviewCampaign struct { + ID string `json:"id"` + Sources []struct { + SourceID *string `json:"sourceId"` + } `json:"sources"` + } `json:"accessReviewCampaign"` + } `json:"updateAccessReviewCampaign"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "accessReviewCampaignId": campaignID, + "accessReviewSourceIds": []string{source2ID}, + }, + }, &result) + require.NoError(t, err) + + assert.Equal(t, campaignID, result.UpdateAccessReviewCampaign.AccessReviewCampaign.ID) + require.Len(t, result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources, 1) + require.NotNil(t, result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources[0].SourceID) + assert.Equal(t, source2ID, *result.UpdateAccessReviewCampaign.AccessReviewCampaign.Sources[0].SourceID) +} + func TestAccessReviewCampaign_Delete(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/packages/n8n-node/nodes/Probo/GenericFunctions.ts b/packages/n8n-node/nodes/Probo/GenericFunctions.ts index 78aa5d868..447067a2f 100644 --- a/packages/n8n-node/nodes/Probo/GenericFunctions.ts +++ b/packages/n8n-node/nodes/Probo/GenericFunctions.ts @@ -21,6 +21,7 @@ import type { IExecuteFunctions, IHookFunctions, + ILoadOptionsFunctions, IDataObject, JsonObject, IHttpRequestOptions, @@ -35,8 +36,10 @@ type ApiRequestFn = ( variables?: IDataObject, ) => Promise; +type ApiRequestContext = IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions; + async function proboGraphqlRequest( - this: IExecuteFunctions | IHookFunctions, + this: ApiRequestContext, apiPath: string, query: string, variables: IDataObject = {}, @@ -82,7 +85,7 @@ async function proboGraphqlRequest( } export async function proboApiRequest( - this: IExecuteFunctions | IHookFunctions, + this: ApiRequestContext, query: string, variables: IDataObject = {}, ): Promise { @@ -160,7 +163,7 @@ export async function proboApiRequestAllItems( ): Promise { return proboGraphqlRequestAllItems.call( this, - proboApiRequest, + proboApiRequest as ApiRequestFn, query, variables, getConnection, @@ -179,7 +182,7 @@ export async function proboConnectApiRequestAllItems( ): Promise { return proboGraphqlRequestAllItems.call( this, - proboConnectApiRequest, + proboConnectApiRequest as ApiRequestFn, query, variables, getConnection, diff --git a/packages/n8n-node/nodes/Probo/Probo.node.ts b/packages/n8n-node/nodes/Probo/Probo.node.ts index 6367481e6..a03b9b30f 100644 --- a/packages/n8n-node/nodes/Probo/Probo.node.ts +++ b/packages/n8n-node/nodes/Probo/Probo.node.ts @@ -31,6 +31,7 @@ import { getAllResourceFields, getExecuteFunction, } from './actions'; +import { getAccessReviewSources } from './actions/accessReview/loadAccessReviewSourceOptions'; export class Probo implements INodeType { description: INodeTypeDescription = { @@ -273,6 +274,9 @@ export class Probo implements INodeType { } methods = { + loadOptions: { + getAccessReviewSources, + }, listSearch: {}, }; } diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/addSource.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/addSource.operation.ts deleted file mode 100644 index 1056921e5..000000000 --- a/packages/n8n-node/nodes/Probo/actions/accessReview/addSource.operation.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; -import { proboApiRequest } from '../../GenericFunctions'; - -export const description: INodeProperties[] = [ - { - displayName: 'Access Review Campaign ID', - name: 'accessReviewCampaignId', - type: 'string', - displayOptions: { - show: { - resource: ['accessReview'], - operation: ['addSource'], - }, - }, - default: '', - description: 'The ID of the access review campaign', - required: true, - }, - { - displayName: 'Access Review Source ID', - name: 'accessReviewSourceId', - type: 'string', - displayOptions: { - show: { - resource: ['accessReview'], - operation: ['addSource'], - }, - }, - default: '', - description: 'The ID of the access review source to add to the campaign', - required: true, - }, -]; - -export async function execute( - this: IExecuteFunctions, - itemIndex: number, -): Promise { - const accessReviewCampaignId = this.getNodeParameter('accessReviewCampaignId', itemIndex) as string; - const accessReviewSourceId = this.getNodeParameter('accessReviewSourceId', itemIndex) as string; - - const query = ` - mutation AddAccessReviewCampaignSource($input: AddAccessReviewCampaignSourceInput!) { - addAccessReviewCampaignSource(input: $input) { - accessReviewCampaign { - id - name - description - status - startedAt - completedAt - createdAt - updatedAt - } - } - } - `; - - const responseData = await proboApiRequest.call(this, query, { - input: { accessReviewCampaignId, accessReviewSourceId }, - }); - - return { - json: responseData, - pairedItem: { item: itemIndex }, - }; -} diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/create.operation.ts index e4c8e8f80..20739dfcf 100644 --- a/packages/n8n-node/nodes/Probo/actions/accessReview/create.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/accessReview/create.operation.ts @@ -20,6 +20,7 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { proboApiRequest } from '../../GenericFunctions'; +import { accessReviewSourceIdsField } from './sources.fields'; export const description: INodeProperties[] = [ { @@ -63,6 +64,7 @@ export const description: INodeProperties[] = [ default: '', description: 'The description of the access review campaign', }, + accessReviewSourceIdsField, ]; export async function execute( @@ -72,6 +74,7 @@ export async function execute( const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; const name = this.getNodeParameter('name', itemIndex) as string; const description = this.getNodeParameter('description', itemIndex, '') as string; + const accessReviewSourceIds = this.getNodeParameter('accessReviewSourceIds', itemIndex, []) as string[]; const query = ` mutation CreateAccessReviewCampaign($input: CreateAccessReviewCampaignInput!) { @@ -97,6 +100,7 @@ export async function execute( organizationId, name, ...(description && { description }), + ...(accessReviewSourceIds.length > 0 && { accessReviewSourceIds }), }, }; diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/index.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/index.ts index 4a71bd6e5..18eb48ba5 100644 --- a/packages/n8n-node/nodes/Probo/actions/accessReview/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/accessReview/index.ts @@ -19,7 +19,6 @@ // SOFTWARE. import type { INodeProperties } from 'n8n-workflow'; -import * as addSourceOp from './addSource.operation'; import * as createOp from './create.operation'; import * as deleteOp from './delete.operation'; import * as getOp from './get.operation'; @@ -41,12 +40,6 @@ export const description: INodeProperties[] = [ }, }, options: [ - { - name: 'Add Source', - value: 'addSource', - description: 'Add a scope source to an access review campaign', - action: 'Add a scope source to an access review campaign', - }, { name: 'Cancel', value: 'cancel', @@ -98,7 +91,6 @@ export const description: INodeProperties[] = [ ], default: 'create', }, - ...addSourceOp.description, ...createOp.description, ...deleteOp.description, ...getOp.description, @@ -110,7 +102,6 @@ export const description: INodeProperties[] = [ ]; export { - addSourceOp as addSource, createOp as create, deleteOp as delete, getOp as get, diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/loadAccessReviewSourceOptions.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/loadAccessReviewSourceOptions.ts new file mode 100644 index 000000000..f85ea53ee --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/accessReview/loadAccessReviewSourceOptions.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +const organizationSourcesQuery = ` + query AccessReviewSources($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + accessReviewSources(first: 500) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +const campaignOrganizationQuery = ` + query AccessReviewCampaignOrganization($accessReviewCampaignId: ID!) { + campaign: node(id: $accessReviewCampaignId) { + ... on AccessReviewCampaign { + organization { + id + } + } + } + } +`; + +function mapSources(responseData: IDataObject): INodePropertyOptions[] { + const data = responseData.data as IDataObject | undefined; + const organization = data?.organization as IDataObject | undefined; + const accessReviewSources = organization?.accessReviewSources as IDataObject | undefined; + const edges = accessReviewSources?.edges as Array<{ node: IDataObject }> | undefined; + + return (edges ?? []) + .map((edge) => edge.node) + .filter((node): node is IDataObject => node !== undefined && typeof node.id === 'string') + .map((node) => ({ + name: String(node.name ?? node.id), + value: String(node.id), + })); +} + +async function resolveOrganizationId( + this: ILoadOptionsFunctions, +): Promise { + const organizationId = this.getCurrentNodeParameter('organizationId') as string | undefined; + if (organizationId) { + return organizationId; + } + + const accessReviewCampaignId = this.getCurrentNodeParameter('accessReviewCampaignId') as string | undefined; + if (!accessReviewCampaignId) { + return undefined; + } + + const responseData = await proboApiRequest.call(this, campaignOrganizationQuery, { + accessReviewCampaignId, + }); + const data = responseData.data as IDataObject | undefined; + const campaign = data?.campaign as IDataObject | undefined; + const organization = campaign?.organization as IDataObject | undefined; + + return typeof organization?.id === 'string' ? organization.id : undefined; +} + +export async function getAccessReviewSources( + this: ILoadOptionsFunctions, +): Promise { + const organizationId = await resolveOrganizationId.call(this); + if (!organizationId) { + return []; + } + + const responseData = await proboApiRequest.call(this, organizationSourcesQuery, { + organizationId, + }); + + return mapSources(responseData); +} diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/sources.fields.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/sources.fields.ts new file mode 100644 index 000000000..d0445633c --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/accessReview/sources.fields.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties } from 'n8n-workflow'; + +export const accessReviewSourceIdsField: INodeProperties = { + displayName: 'Source Names or IDs', + name: 'accessReviewSourceIds', + type: 'multiOptions', + displayOptions: { + show: { + resource: ['accessReview'], + operation: ['create'], + }, + }, + typeOptions: { + loadOptionsMethod: 'getAccessReviewSources', + loadOptionsDependsOn: ['organizationId'], + }, + default: [], + description: 'Scope sources to include in the campaign. Choose from the list, or specify IDs using an expression.', +}; + +export const accessReviewSourceIdsUpdateField: INodeProperties = { + displayName: 'Source Names or IDs', + name: 'accessReviewSourceIds', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getAccessReviewSources', + loadOptionsDependsOn: ['accessReviewCampaignId'], + }, + default: [], + description: 'Replace the campaign scope sources with this selection. Choose from the list, or specify IDs using an expression.', +}; diff --git a/packages/n8n-node/nodes/Probo/actions/accessReview/update.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReview/update.operation.ts index 325f7da2c..e98b48d4e 100644 --- a/packages/n8n-node/nodes/Probo/actions/accessReview/update.operation.ts +++ b/packages/n8n-node/nodes/Probo/actions/accessReview/update.operation.ts @@ -20,6 +20,7 @@ import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { proboApiRequest } from '../../GenericFunctions'; +import { accessReviewSourceIdsUpdateField } from './sources.fields'; export const description: INodeProperties[] = [ { @@ -62,6 +63,20 @@ export const description: INodeProperties[] = [ default: '', description: 'The description of the access review campaign', }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['accessReview'], + operation: ['update'], + }, + }, + options: [accessReviewSourceIdsUpdateField], + }, ]; export async function execute( @@ -71,6 +86,9 @@ export async function execute( const accessReviewCampaignId = this.getNodeParameter('accessReviewCampaignId', itemIndex) as string; const name = this.getNodeParameter('name', itemIndex, '') as string; const description = this.getNodeParameter('description', itemIndex, '') as string; + const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as { + accessReviewSourceIds?: string[]; + }; const query = ` mutation UpdateAccessReviewCampaign($input: UpdateAccessReviewCampaignInput!) { @@ -89,9 +107,12 @@ export async function execute( } `; - const input: Record = { accessReviewCampaignId }; + const input: Record = { accessReviewCampaignId }; if (name) input.name = name; if (description) input.description = description; + if (additionalFields.accessReviewSourceIds !== undefined) { + input.accessReviewSourceIds = additionalFields.accessReviewSourceIds; + } const responseData = await proboApiRequest.call(this, query, { input }); diff --git a/pkg/accessreview/campaign_service.go b/pkg/accessreview/campaign_service.go index 1a37d994f..c96b65a06 100644 --- a/pkg/accessreview/campaign_service.go +++ b/pkg/accessreview/campaign_service.go @@ -171,6 +171,12 @@ func (s *Service) UpdateCampaign( return fmt.Errorf("cannot update campaign: %w", err) } + if req.AccessReviewSourceIDs != nil { + if err := s.syncCampaignSources(ctx, conn, scope, campaign, *req.AccessReviewSourceIDs); err != nil { + return err + } + } + return nil }, ) @@ -294,6 +300,77 @@ func (s *Service) RemoveCampaignSource( return campaign, nil } +func (s *Service) syncCampaignSources( + ctx context.Context, + conn pg.Tx, + scope coredata.Scoper, + campaign *coredata.AccessReviewCampaign, + sourceIDs []gid.GID, +) error { + var campaignSources coredata.AccessReviewCampaignSources + if err := campaignSources.LoadByCampaignID(ctx, conn, scope, campaign.ID); err != nil { + return fmt.Errorf("cannot load campaign sources: %w", err) + } + + existingSourceIDs := make([]gid.GID, 0, len(campaignSources)) + for _, campaignSource := range campaignSources { + if campaignSource.AccessReviewSourceID != nil { + existingSourceIDs = append(existingSourceIDs, *campaignSource.AccessReviewSourceID) + } + } + + for _, sourceID := range sourceIDs { + if containsGID(existingSourceIDs, sourceID) { + continue + } + + source := &coredata.AccessReviewSource{} + if err := source.LoadByID(ctx, conn, scope, sourceID); err != nil { + return fmt.Errorf("cannot load access source %s: %w", sourceID, err) + } + + if source.OrganizationID != campaign.OrganizationID { + return fmt.Errorf( + "cannot update campaign: access source %s does not belong to the same organization", + sourceID, + ) + } + + if err := s.upsertCampaignSource(ctx, conn, scope, campaign.ID, source); err != nil { + return fmt.Errorf("cannot snapshot scope source: %w", err) + } + } + + for _, existingSourceID := range existingSourceIDs { + if containsGID(sourceIDs, existingSourceID) { + continue + } + + campaignSource := &coredata.AccessReviewCampaignSource{} + if err := campaignSource.DeleteByCampaignIDAndAccessReviewSourceID( + ctx, + conn, + scope, + campaign.ID, + existingSourceID, + ); err != nil { + return fmt.Errorf("cannot delete campaign source: %w", err) + } + } + + return nil +} + +func containsGID(ids []gid.GID, id gid.GID) bool { + for _, candidate := range ids { + if candidate == id { + return true + } + } + + return false +} + func (s *Service) StartCampaign( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/accessreview/campaign_types.go b/pkg/accessreview/campaign_types.go index aaa89a0df..54002b6ba 100644 --- a/pkg/accessreview/campaign_types.go +++ b/pkg/accessreview/campaign_types.go @@ -37,9 +37,10 @@ type ( } UpdateAccessReviewCampaignRequest struct { - CampaignID gid.GID - Name **string - Description **string + CampaignID gid.GID + Name **string + Description **string + AccessReviewSourceIDs *[]gid.GID } AddCampaignSourceRequest struct { diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 3edce2568..e4ebfa8a6 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -746,9 +746,10 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input ctx, scope, accessreview.UpdateAccessReviewCampaignRequest{ - CampaignID: input.AccessReviewCampaignID, - Name: gqlutils.UnwrapOmittable(input.Name), - Description: gqlutils.UnwrapOmittable(input.Description), + CampaignID: input.AccessReviewCampaignID, + Name: gqlutils.UnwrapOmittable(input.Name), + Description: gqlutils.UnwrapOmittable(input.Description), + AccessReviewSourceIDs: gqlutils.UnwrapOmittable(input.AccessReviewSourceIds), }, ) if err != nil { @@ -756,6 +757,10 @@ func (r *mutationResolver) UpdateAccessReviewCampaign(ctx context.Context, input return nil, gqlutils.NotFound(ctx, err) } + if strings.HasPrefix(err.Error(), "cannot update campaign:") { + return nil, gqlutils.Invalid(ctx, err) + } + r.logger.ErrorCtx(ctx, "cannot update access review campaign", log.Error(err)) return nil, gqlutils.Internal(ctx) diff --git a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql index 38b7a7ff8..ca0351d57 100644 --- a/pkg/server/api/console/v1/graphql/access_review_campaign.graphql +++ b/pkg/server/api/console/v1/graphql/access_review_campaign.graphql @@ -588,6 +588,7 @@ input UpdateAccessReviewCampaignInput { accessReviewCampaignId: ID! name: String @goField(omittable: true) description: String @goField(omittable: true) + accessReviewSourceIds: [ID!] @goField(omittable: true) } type UpdateAccessReviewCampaignPayload {