Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/gql-schema/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const schema = `
hasUnassignedContacts: Boolean
hasUnsentInitialMessages: Boolean
hasUnhandledMessages: Boolean
hasSentMessages: Boolean!
customFields: [String]
customFieldAverageLengths: JSON!
cannedResponses(userId: String): [CannedResponse!]!
Expand Down
1 change: 1 addition & 0 deletions libs/gql-schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
8 changes: 8 additions & 0 deletions libs/spoke-codegen/src/graphql/campaign-builder.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ mutation StartCampaign($campaignId: String!) {
isApproved
}
}

mutation UnstartCampaign($campaignId: String!) {
unstartCampaign(id: $campaignId) {
id
isStarted
isApproved
}
}
Original file line number Diff line number Diff line change
@@ -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<ArchiveCampaignButtonProps> = (
props
) => {
const { campaignId, isArchived } = props;
const [setCampaignArchived] = useSetCampaignArchivedMutation();

return (
<Button
variant="outlined"
onClick={() =>
setCampaignArchived({
variables: { campaignId, archived: !isArchived }
})
}
>
{isArchived ? "Unarchive" : "Archive"}
</Button>
);
};

export default ArchiveCampaignButton;
Original file line number Diff line number Diff line change
@@ -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<UnstartCampaignButtonProps> = (
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 (
<Button variant="outlined" disabled={unstarting} onClick={handleClick}>
Unstart
</Button>
);
};

export default UnstartCampaignButton;
118 changes: 61 additions & 57 deletions src/containers/AdminCampaignEdit/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,22 @@ 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,
GET_CAMPAIGN_JOBS,
GET_EDIT_CAMPAIGN_DATA,
GET_ORGANIZATION_ACTIONS,
GET_ORGANIZATION_DATA,
SET_CAMPAIGN_ARCHIVED,
START_CAMPAIGN
} from "./queries";
import CampaignAutoassignModeForm from "./sections/CampaignAutoassignModeForm";
Expand Down Expand Up @@ -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 = (
<div
{...dataTest("campaignIsStarted")}
style={{
color: isOverdue ? red[600] : theme.colors.green
...theme.layouts.multiColumn.container
}}
>
{isOverdue
? "This campaign is running but is overdue!"
: "This campaign is running!"}
{this.renderCurrentEditors()}
<div
style={{
...theme.layouts.multiColumn.flexColumn,
...(isStarted && {
color: isOverdue ? red[600] : theme.colors.green
})
}}
>
{statusText}
{this.renderCurrentEditors()}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{this.renderHeaderButtons(isCampaignReady)}
</div>
</div>
) : (
this.renderStartButton()
);

return (
Expand Down Expand Up @@ -733,19 +749,13 @@ class AdminCampaignEdit extends React.Component {
Starting your campaign...
</div>
)}
{!isTemplate && !this.state.startingCampaign && notStarting}
{!isTemplate && !this.state.startingCampaign && header}
</div>
);
};

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) =>
Expand All @@ -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 (
<div
style={{
...theme.layouts.multiColumn.container
}}
>
<div
style={{
...theme.layouts.multiColumn.flexColumn
}}
>
{isCompleted
? "Your campaign is all good to go!"
: "You need to complete all the sections below before you can start this campaign"}
{this.renderCurrentEditors()}
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
variant="outlined"
onClick={() =>
mutations.setCampaignArchived(campaign.id, !campaign.isArchived)
}
>
{campaign.isArchived ? "Unarchive" : "Archive"}
</Button>
<ApproveCampaignButton campaignId={campaign.id} />
<StartCampaignButton
campaignId={campaign.id}
isCompleted={isCompleted}
/>
</div>
</div>
<>
<ArchiveCampaignButton
campaignId={campaign.id}
isArchived={campaign.isArchived}
/>
{campaign.isStarted ? (
!campaign.hasSentMessages && (
<UnstartCampaignButton
campaignId={campaign.id}
onError={this.handleSectionError}
/>
)
) : (
<>
<ApproveCampaignButton campaignId={campaign.id} />
<StartCampaignButton
campaignId={campaign.id}
isCompleted={isCampaignReady}
/>
</>
)}
</>
);
};

Expand Down Expand Up @@ -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 }
Expand Down
10 changes: 1 addition & 9 deletions src/containers/AdminCampaignEdit/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const EditCampaignFragment = gql`
isApproved
isArchived
isTemplate
hasSentMessages
contactsCount
datawarehouseAvailable
customFields
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -663,6 +664,7 @@ type Campaign {
hasUnassignedContacts: Boolean
hasUnsentInitialMessages: Boolean
hasUnhandledMessages: Boolean
hasSentMessages: Boolean!
customFields: [String]
customFieldAverageLengths: JSON
cannedResponses(userId: String): [CannedResponse!]!
Expand Down
7 changes: 6 additions & 1 deletion src/server/api/campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions src/server/api/lib/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about the use of r.reader.raw here instead of r.knex.raw. This might be intentional, but wouldn’t it make sense to check the primary db so we don’t miss a recently created message?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep totally makes sense. tbh i think the way it's been used in the codebase for the most part has been very simplistic "reads -> reader, writes -> knex" so i'll fix this and make a followup issue for more comprehensive review of other places this could happen which is definitely needed 😅

`
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
Expand Down
25 changes: 25 additions & 0 deletions src/server/api/root-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { getStepsToUpdate } from "./lib/bulk-script-editor";
import {
copyCampaign,
editCampaign,
hasSentMessages,
markAutosendingPaused,
unqueueAutosending
} from "./lib/campaign";
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we we also stop any queued autosend jobs here? Thinking about the case where messages already waiting in the queue could still be sent after the campaign is unstarted, even though no message records exist yet

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch! I think rather than deleting the jobs, we should just check autosend_status = unstarted as a condition alongside the message one, I think it's fair to say once they hit "start sending" the campaign is truly started and we won't let them unstart at that point

.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 },
Expand Down
Loading