From f09ffce4677c11bad7c47ba58eff8f98a5845a82 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Sat, 15 Aug 2026 18:51:10 -0400 Subject: [PATCH] feat(campaign-builder): allow unstarting campaign --- libs/gql-schema/campaign.ts | 1 + libs/gql-schema/schema.ts | 1 + .../src/graphql/campaign-builder.graphql | 8 ++ .../components/ArchiveCampaignButton.tsx | 30 +++++ .../components/UnstartCampaignButton.tsx | 33 +++++ src/containers/AdminCampaignEdit/index.jsx | 118 +++++++++--------- src/containers/AdminCampaignEdit/queries.ts | 10 +- src/schema.graphql | 2 + src/server/api/campaign.js | 7 +- src/server/api/lib/campaign.ts | 14 +++ src/server/api/root-mutations.ts | 25 ++++ 11 files changed, 182 insertions(+), 67 deletions(-) create mode 100644 src/containers/AdminCampaignEdit/components/ArchiveCampaignButton.tsx create mode 100644 src/containers/AdminCampaignEdit/components/UnstartCampaignButton.tsx diff --git a/libs/gql-schema/campaign.ts b/libs/gql-schema/campaign.ts index 40fdf865a..1a5effec0 100644 --- a/libs/gql-schema/campaign.ts +++ b/libs/gql-schema/campaign.ts @@ -96,6 +96,7 @@ export const schema = ` hasUnassignedContacts: Boolean hasUnsentInitialMessages: Boolean hasUnhandledMessages: Boolean + hasSentMessages: Boolean! customFields: [String] customFieldAverageLengths: JSON! cannedResponses(userId: String): [CannedResponse!]! diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 0b1070778..23d8fd676 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -330,6 +330,7 @@ const rootSchema = ` ): CampaignContact, setCampaignApproved(id: String!, approved: Boolean!): Campaign!, startCampaign(id:String!): Campaign, + unstartCampaign(id:String!): Campaign, setCampaignArchived(id: String!, archived: Boolean!): Campaign!, sendReply(id: String!, message: String!): CampaignContact getAssignmentContacts(assignmentId: String!, contactIds: [String], findNew: Boolean): [CampaignContact], diff --git a/libs/spoke-codegen/src/graphql/campaign-builder.graphql b/libs/spoke-codegen/src/graphql/campaign-builder.graphql index 6f2db768a..fadc93103 100644 --- a/libs/spoke-codegen/src/graphql/campaign-builder.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-builder.graphql @@ -77,3 +77,11 @@ mutation StartCampaign($campaignId: String!) { isApproved } } + +mutation UnstartCampaign($campaignId: String!) { + unstartCampaign(id: $campaignId) { + id + isStarted + isApproved + } +} diff --git a/src/containers/AdminCampaignEdit/components/ArchiveCampaignButton.tsx b/src/containers/AdminCampaignEdit/components/ArchiveCampaignButton.tsx new file mode 100644 index 000000000..94feaa548 --- /dev/null +++ b/src/containers/AdminCampaignEdit/components/ArchiveCampaignButton.tsx @@ -0,0 +1,30 @@ +import Button from "@material-ui/core/Button"; +import { useSetCampaignArchivedMutation } from "@spoke/spoke-codegen"; +import React from "react"; + +export interface ArchiveCampaignButtonProps { + campaignId: string; + isArchived: boolean; +} + +export const ArchiveCampaignButton: React.FC = ( + props +) => { + const { campaignId, isArchived } = props; + const [setCampaignArchived] = useSetCampaignArchivedMutation(); + + return ( + + ); +}; + +export default ArchiveCampaignButton; diff --git a/src/containers/AdminCampaignEdit/components/UnstartCampaignButton.tsx b/src/containers/AdminCampaignEdit/components/UnstartCampaignButton.tsx new file mode 100644 index 000000000..8ed445f11 --- /dev/null +++ b/src/containers/AdminCampaignEdit/components/UnstartCampaignButton.tsx @@ -0,0 +1,33 @@ +import Button from "@material-ui/core/Button"; +import { useUnstartCampaignMutation } from "@spoke/spoke-codegen"; +import React from "react"; + +export interface UnstartCampaignButtonProps { + campaignId: string; + onError: (errorMessage: string) => void; +} + +export const UnstartCampaignButton: React.FC = ( + props +) => { + const { campaignId, onError } = props; + const [ + unstartCampaign, + { loading: unstarting } + ] = useUnstartCampaignMutation(); + + const handleClick = async () => { + const result = await unstartCampaign({ variables: { campaignId } }); + if (result.errors) { + onError(result.errors.map((error) => error.message).join(", ")); + } + }; + + return ( + + ); +}; + +export default UnstartCampaignButton; diff --git a/src/containers/AdminCampaignEdit/index.jsx b/src/containers/AdminCampaignEdit/index.jsx index 5405f71de..352e0e553 100644 --- a/src/containers/AdminCampaignEdit/index.jsx +++ b/src/containers/AdminCampaignEdit/index.jsx @@ -23,14 +23,15 @@ import { compose } from "recompose"; import { withSpokeContext } from "../../client/spoke-context"; import CampaignNavigation from "../../components/CampaignNavigation"; -import { dataTest } from "../../lib/attributes"; import { DateTime } from "../../lib/datetime"; import theme from "../../styles/theme"; import { withAuthzContext } from "../AuthzProvider"; import { loadData } from "../hoc/with-operations"; import ApproveCampaignButton from "./components/ApproveCampaignButton"; +import ArchiveCampaignButton from "./components/ArchiveCampaignButton"; import { SectionWrapper } from "./components/SectionWrapper"; import StartCampaignButton from "./components/StartCampaignButton"; +import UnstartCampaignButton from "./components/UnstartCampaignButton"; import { DELETE_JOB, EDIT_CAMPAIGN, @@ -38,7 +39,6 @@ import { GET_EDIT_CAMPAIGN_DATA, GET_ORGANIZATION_ACTIONS, GET_ORGANIZATION_DATA, - SET_CAMPAIGN_ARCHIVED, START_CAMPAIGN } from "./queries"; import CampaignAutoassignModeForm from "./sections/CampaignAutoassignModeForm"; @@ -652,21 +652,37 @@ class AdminCampaignEdit extends React.Component { } = this.props.campaignData; const isOverdue = DateTime.local() >= DateTime.fromISO(dueBy); + const isCampaignReady = !isStarted && this.isCampaignReadyToStart(); - const notStarting = isStarted ? ( + const statusText = isStarted + ? isOverdue + ? "This campaign is running but is overdue!" + : "This campaign is running!" + : isCampaignReady + ? "Your campaign is all good to go!" + : "You need to complete all the sections below before you can start this campaign"; + + const header = (
- {isOverdue - ? "This campaign is running but is overdue!" - : "This campaign is running!"} - {this.renderCurrentEditors()} +
+ {statusText} + {this.renderCurrentEditors()} +
+
+ {this.renderHeaderButtons(isCampaignReady)} +
- ) : ( - this.renderStartButton() ); return ( @@ -733,19 +749,13 @@ class AdminCampaignEdit extends React.Component { Starting your campaign... )} - {!isTemplate && !this.state.startingCampaign && notStarting} + {!isTemplate && !this.state.startingCampaign && header} ); }; - renderStartButton = () => { - const { isAdmin, campaignData, pendingJobsData, mutations } = this.props; - - if (!isAdmin) { - // Supervolunteers don't have access to start the campaign or un/archive it - return null; - } - const { campaign } = campaignData; + isCampaignReadyToStart = () => { + const { pendingJobsData } = this.props; let isCompleted = pendingJobsData.campaign.pendingJobs.filter((job) => @@ -756,43 +766,41 @@ class AdminCampaignEdit extends React.Component { if ( (section.blocksStarting && !this.checkSectionCompleted(section)) || !this.checkSectionSaved(section) - ) { + ) isCompleted = false; - } }); + return isCompleted; + }; + + renderHeaderButtons = (isCampaignReady) => { + const { isAdmin, campaignData } = this.props; + if (!isAdmin) return null; + const { campaign } = campaignData; + return ( -
-
- {isCompleted - ? "Your campaign is all good to go!" - : "You need to complete all the sections below before you can start this campaign"} - {this.renderCurrentEditors()} -
-
- - - -
-
+ <> + + {campaign.isStarted ? ( + !campaign.hasSentMessages && ( + + ) + ) : ( + <> + + + + )} + ); }; @@ -943,10 +951,6 @@ const queries = { }; const mutations = { - setCampaignArchived: (_ownProps) => (campaignId, archived) => ({ - mutation: SET_CAMPAIGN_ARCHIVED, - variables: { campaignId, archived } - }), startCampaign: (_ownProps) => (campaignId) => ({ mutation: START_CAMPAIGN, variables: { campaignId } diff --git a/src/containers/AdminCampaignEdit/queries.ts b/src/containers/AdminCampaignEdit/queries.ts index fc03c90d4..8c440265b 100644 --- a/src/containers/AdminCampaignEdit/queries.ts +++ b/src/containers/AdminCampaignEdit/queries.ts @@ -78,6 +78,7 @@ export const EditCampaignFragment = gql` isApproved isArchived isTemplate + hasSentMessages contactsCount datawarehouseAvailable customFields @@ -123,15 +124,6 @@ export const GET_EDIT_CAMPAIGN_DATA = gql` ${EditCampaignFragment} `; -export const SET_CAMPAIGN_ARCHIVED = gql` - mutation setCampaignArchived($campaignId: String!, $archived: Boolean!) { - setCampaignArchived(id: $campaignId, archived: $archived) { - ...EditCampaignFragment - } - } - ${EditCampaignFragment} -`; - export const START_CAMPAIGN = gql` mutation startCampaign($campaignId: String!) { startCampaign(id: $campaignId) { diff --git a/src/schema.graphql b/src/schema.graphql index 43f37b2ca..0773c937a 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -296,6 +296,7 @@ type RootMutation { ): CampaignContact, setCampaignApproved(id: String!, approved: Boolean!): Campaign!, startCampaign(id:String!): Campaign, + unstartCampaign(id:String!): Campaign, setCampaignArchived(id: String!, archived: Boolean!): Campaign!, sendReply(id: String!, message: String!): CampaignContact getAssignmentContacts(assignmentId: String!, contactIds: [String], findNew: Boolean): [CampaignContact], @@ -663,6 +664,7 @@ type Campaign { hasUnassignedContacts: Boolean hasUnsentInitialMessages: Boolean hasUnhandledMessages: Boolean + hasSentMessages: Boolean! customFields: [String] customFieldAverageLengths: JSON cannedResponses(userId: String): [CannedResponse!]! diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 199d6d6d1..9c7ac11c5 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -9,7 +9,11 @@ import MemoizeHelper, { Buckets, cacheOpts } from "../memoredis"; import { cacheableData, r } from "../models"; import { currentEditors } from "../models/cacheable_queries"; import { accessRequired } from "./errors"; -import { getDeliverabilityStats, invalidScriptFields } from "./lib/campaign"; +import { + getDeliverabilityStats, + hasSentMessages, + invalidScriptFields +} from "./lib/campaign"; import { symmetricEncrypt } from "./lib/crypto"; import { getMessagingServiceById } from "./lib/message-sending"; import { formatPage } from "./lib/pagination"; @@ -764,6 +768,7 @@ export const resolvers = { organizationId: campaign.organization_id }); }, + hasSentMessages: async (campaign) => hasSentMessages(campaign.id), customFields: async (campaign) => campaign.customFields || cacheableData.campaign.dbCustomFields(campaign.id), diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index da8be3403..5c6126896 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -814,6 +814,20 @@ export const editCampaign = async ( return newCampaign || loaders.campaign.load(id); }; +export const hasSentMessages = async (campaignId: string) => { + const { rows } = await r.reader.raw( + ` + select exists ( + select 1 from message m + join campaign_contact cc on cc.id = m.campaign_contact_id + where campaign_id = ? + ) as message_exists + `, + [campaignId] + ); + return rows[0] && rows[0].message_exists; +}; + export const invalidScriptFields = async (campaignId: string) => { const { rows: variables } = await r.knex.raw( // eslint-disable-next-line no-useless-escape diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index ccaa07016..a8f866cbf 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -59,6 +59,7 @@ import { getStepsToUpdate } from "./lib/bulk-script-editor"; import { copyCampaign, editCampaign, + hasSentMessages, markAutosendingPaused, unqueueAutosending } from "./lib/campaign"; @@ -1016,6 +1017,30 @@ const rootMutations = { return campaign; }, + unstartCampaign: async (_root, { id }, { user, loaders }) => { + const { organization_id } = await loaders.campaign.load(id); + await accessRequired(user, organization_id, "ADMIN", true); + + if (await hasSentMessages(id)) { + throw new ForbiddenError( + "Campaign cannot be unstarted after messages have been sent." + ); + } + + const [campaign] = await r + .knex("campaign") + .update({ is_started: false }) + .where({ id }) + .returning("*"); + + const memoizer = await MemoizeHelper.getMemoizer(); + await memoizer.invalidate(cacheOpts.CampaignsList.key, { + organizationId: organization_id + }); + + return campaign; + }, + editCampaign: async ( _root, { id, campaign: campaignEdits },