Skip to content

Commit

Permalink
refactor(second-pass): implement as task
Browse files Browse the repository at this point in the history
  • Loading branch information
ajohn25 committed Sep 3, 2024
1 parent 353f53e commit d7a048b
Show file tree
Hide file tree
Showing 14 changed files with 358 additions and 124 deletions.
4 changes: 2 additions & 2 deletions libs/gql-schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,11 @@ const rootSchema = `
requestTexts(count: Int!, email: String!, organizationId: String!, preferredTeamId: String!): String!
releaseMessages(campaignId: String!, target: ReleaseActionTarget!, ageInHours: Float): String!
releaseAllUnhandledReplies(organizationId: String!, ageInHours: Float, releaseOnRestricted: Boolean, limitToCurrentlyTextableContacts: Boolean): ReleaseAllUnhandledRepliesResult!
markForSecondPass(campaignId: String!, input: SecondPassInput!): String!
markForSecondPass(campaignId: String!, campaignTitle: String!, input: SecondPassInput!): String!
startAutosending(campaignId: String!): Campaign!
pauseAutosending(campaignId: String!): Campaign!
updateCampaignAutosendingLimit(campaignId: String!, limit: Int): Campaign!
unMarkForSecondPass(campaignId: String!): String!
unMarkForSecondPass(campaignId: String!, campaignTitle: String!): String!
deleteNeedsMessage(campaignId: String!): String!
insertLinkDomain(organizationId: String!, domain: String!, maxUsageCount: Int!): LinkDomain!
updateLinkDomain(organizationId: String!, domainId: String!, payload: UpdateLinkDomain!): LinkDomain!
Expand Down
8 changes: 4 additions & 4 deletions libs/spoke-codegen/src/graphql/campaign-operations.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ mutation deleteNeedsMessage($campaignId: String!) {
deleteNeedsMessage(campaignId: $campaignId)
}

mutation markForSecondPass($campaignId: String!, $input: SecondPassInput!) {
markForSecondPass(campaignId: $campaignId, input: $input)
mutation markForSecondPass($campaignId: String!, $campaignTitle: String!, $input: SecondPassInput!) {
markForSecondPass(campaignId: $campaignId, campaignTitle: $campaignTitle, input: $input)
}

mutation unMarkForSecondPass($campaignId: String!) {
unMarkForSecondPass(campaignId: $campaignId)
mutation unMarkForSecondPass($campaignId: String!, $campaignTitle: String!) {
unMarkForSecondPass(campaignId: $campaignId, campaignTitle: $campaignTitle)
}

mutation toggleAutoAssign($campaignId: String!, $enabled: Boolean!) {
Expand Down
4 changes: 4 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,10 @@ const validators = {
"A JSON blob passed directly to express-basic-auth for locking campaign previews",
default: undefined
}),
MARK_SECOND_PASS_CHUNK_SIZE: num({
desc: "Chunk size to use when marking a campaign for a second pass",
default: 1000
}),
SKIP_TWILIO_VALIDATION: bool({
desc: "Whether to bypass Twilio header validation altogether.",
default: false
Expand Down
7 changes: 6 additions & 1 deletion src/containers/CampaignList/components/OperationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export interface OperationDialogProps extends OperationDialogBodyProps {

export const OperationDialogBody = (props: OperationDialogBodyProps) => {
const { inProgress, finished, executing, error, setInProgress } = props;

const { name: operationName, campaign } = inProgress;
const operationDefinition = dialogOperations[operationName];

Expand Down Expand Up @@ -89,6 +88,12 @@ export const OperationDialogBody = (props: OperationDialogBodyProps) => {
return (
<div>
<p>{operationDefinition?.body(campaign)}</p>
{campaign.hasUnsentInitialMessages && (
<p style={{ color: "red" }}>
WARNING: This campaign still has contacts with unsent initial
messages{" "}
</p>
)}
<p>
To read about best practices for second passes, head{" "}
<a
Expand Down
23 changes: 19 additions & 4 deletions src/containers/CampaignList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,35 @@ export const CampaignList: React.FC<CampaignListProps> = (props) => {
break;
}
case isMarkForSecondPass(inProgress): {
const { excludeNewer, hours } = inProgress.payload;
const {
excludeNewer,
excludeRecentlyTexted,
hours,
days
} = inProgress.payload;
const { id: campaignId, title: campaignTitle } = campaign;
const excludeAgeInHours = excludeRecentlyTexted
? (days || 0) * 24 + (hours || 0)
: undefined;

const { data, errors } = await markCampaign({
variables: {
campaignId: campaign.id,
input: { excludeNewer, excludeAgeInHours: hours }
campaignId,
campaignTitle,
input: {
excludeNewer,
excludeAgeInHours
}
}
});

setStateAfterOperation(data?.markForSecondPass, errors);
break;
}
case isUnMarkForSecondPass(inProgress): {
const { id: campaignId, title: campaignTitle } = campaign;
const { data, errors } = await unmarkCampaign({
variables: { campaignId: campaign.id }
variables: { campaignId, campaignTitle }
});
setStateAfterOperation(data?.unMarkForSecondPass, errors);
break;
Expand Down
4 changes: 2 additions & 2 deletions src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -301,11 +301,11 @@ type RootMutation {
requestTexts(count: Int!, email: String!, organizationId: String!, preferredTeamId: String!): String!
releaseMessages(campaignId: String!, target: ReleaseActionTarget!, ageInHours: Float): String!
releaseAllUnhandledReplies(organizationId: String!, ageInHours: Float, releaseOnRestricted: Boolean, limitToCurrentlyTextableContacts: Boolean): ReleaseAllUnhandledRepliesResult!
markForSecondPass(campaignId: String!, input: SecondPassInput!): String!
markForSecondPass(campaignId: String!, campaignTitle: String!, input: SecondPassInput!): String!
startAutosending(campaignId: String!): Campaign!
pauseAutosending(campaignId: String!): Campaign!
updateCampaignAutosendingLimit(campaignId: String!, limit: Int): Campaign!
unMarkForSecondPass(campaignId: String!): String!
unMarkForSecondPass(campaignId: String!, campaignTitle: String!): String!
deleteNeedsMessage(campaignId: String!): String!
insertLinkDomain(organizationId: String!, domain: String!, maxUsageCount: Int!): LinkDomain!
updateLinkDomain(organizationId: String!, domainId: String!, payload: UpdateLinkDomain!): LinkDomain!
Expand Down
17 changes: 17 additions & 0 deletions src/server/api/lib/mark-second-pass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* eslint-disable import/prefer-default-export */
import type { User } from "@spoke/spoke-codegen";
import { r } from "src/server/models";

import { accessRequired } from "../errors";

export const getSecondPassCampaign = async (campaignId: number, user: User) => {
// verify permissions
const campaign = await r
.knex("campaign")
.where({ id: campaignId })
.first(["organization_id", "is_archived", "autosend_status"]);

const organizationId = campaign.organization_id;
await accessRequired(user, organizationId, "ADMIN", true);
return campaign;
};
143 changes: 38 additions & 105 deletions src/server/api/root-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { cacheableData, r } from "../models";
import { getUserById } from "../models/cacheable_queries";
import { Notifications, sendUserNotification } from "../notifications";
import { addExportCampaign } from "../tasks/chunk-tasks/export-campaign";
import { addMarkSecondPass } from "../tasks/chunk-tasks/mark-second-pass";
import { addExportForVan } from "../tasks/export-for-van";
import { TASK_IDENTIFIER as exportOptOutsIdentifier } from "../tasks/export-opt-outs";
import { addFilterLandlines } from "../tasks/filter-landlines";
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
markAutosendingPaused,
unqueueAutosending
} from "./lib/campaign";
import { getSecondPassCampaign } from "./lib/mark-second-pass";
import { saveNewIncomingMessage } from "./lib/message-sending";
import { processNumbers } from "./lib/opt-out";
import { sendMessage } from "./lib/send-message";
Expand Down Expand Up @@ -1874,18 +1876,12 @@ const rootMutations = {

markForSecondPass: async (
_ignore,
{ campaignId, input: { excludeAgeInHours, excludeNewer } },
{ campaignId, campaignTitle, input: { excludeAgeInHours, excludeNewer } },
{ user }
) => {
// verify permissions
const campaign = await r
.knex("campaign")
.where({ id: parseInt(campaignId, 10) })
.first(["organization_id", "is_archived", "autosend_status"]);

const organizationId = campaign.organization_id;

await accessRequired(user, organizationId, "ADMIN", true);
const numCampaignId = parseInt(campaignId, 10);
const campaign = await getSecondPassCampaign(campaignId, user);

if (!["complete", "unstarted"].includes(campaign.autosend_status)) {
throw new Error(
Expand All @@ -1895,61 +1891,21 @@ const rootMutations = {
);
}

await r
.knex("campaign")
.update({ autosend_status: "unstarted" })
.where({ id: parseInt(campaignId, 10) });

const queryArgs = [parseInt(campaignId, 10)];
if (excludeAgeInHours) {
queryArgs.push(parseFloat(excludeAgeInHours));
}

const excludeNewerSql = `
and not exists (
select
cell
from
campaign_contact as newer_contact
where
newer_contact.cell = current_contact.cell
and newer_contact.created_at > current_contact.created_at
)
`;

/**
* "Mark Campaign for Second Pass", will only mark contacts for a second
* pass that do not have a more recently created membership in another campaign.
* Using SQL injection to avoid passing archived as a binding
* Should help with guaranteeing partial index usage
*/
const updateSql = `
update
campaign_contact as current_contact
set
message_status = 'needsMessage'
where current_contact.campaign_id = ?
and current_contact.message_status = 'messaged'
and current_contact.archived = ${campaign.is_archived}
${excludeNewer ? excludeNewerSql : ""}
and not exists (
select 1
from message
where current_contact.id = message.campaign_contact_id
and is_from_contact = true
)
${
excludeAgeInHours
? "and current_contact.updated_at < now() - interval '?? hour'"
: ""
}
;
`;
const [{ count: contactsCount }] = await r
.knex("campaign_contact")
.where({ campaign_id: numCampaignId, message_status: "messaged" })
.count();

const updateResultRaw = await r.knex.raw(updateSql, queryArgs);
const updateResult = updateResultRaw.rowCount;
addMarkSecondPass({
organizationId: campaign.organization_id,
campaignId,
campaignTitle,
requesterId: user.id,
excludeAgeInHours,
excludeNewer
});

return `Marked ${updateResult} campaign contacts for a second pass.`;
return `Queuing ${contactsCount} campaign contacts for a second pass. You'll receive an email when the second pass is fully marked!`;
},

startAutosending: async (_ignore, { campaignId }, { loaders, user }) => {
Expand Down Expand Up @@ -2044,53 +2000,30 @@ const rootMutations = {
return updatedCampaign;
},

unMarkForSecondPass: async (_ignore, { campaignId }, { user }) => {
unMarkForSecondPass: async (
_ignore,
{ campaignId, campaignTitle },
{ user }
) => {
// verify permissions
const campaign = await r
.knex("campaign")
.where({ id: parseInt(campaignId, 10) })
.first(["organization_id", "is_archived"]);
const numCampaignId = parseInt(campaignId, 10);
const campaign = await getSecondPassCampaign(numCampaignId, user);

const organizationId = campaign.organization_id;

await accessRequired(user, organizationId, "ADMIN", true);

/**
* "Un-Mark Campaign for Second Pass", will only mark contacts as messaged
* if they are currently needsMessage and have been sent a message and have not replied
*
* Using SQL injection to avoid passing archived as a binding
* Should help with guaranteeing partial index usage
*/
const updateResultRaw = await r.knex.raw(
`
update
campaign_contact
set
message_status = 'messaged'
where campaign_contact.campaign_id = ?
and campaign_contact.message_status = 'needsMessage'
and campaign_contact.archived = ${campaign.is_archived}
and exists (
select 1
from message
where message.campaign_contact_id = campaign_contact.id
and is_from_contact = false
)
and not exists (
select 1
from message
where message.campaign_contact_id = campaign_contact.id
and is_from_contact = true
)
;
`,
[parseInt(campaignId, 10)]
);
// The precise count unmarked may be lower if some contacts never got a first message
const [{ count: contactsCount }] = await r
.knex("campaign_contact")
.where({ campaign_id: numCampaignId, message_status: "needsMessage" })
.count();

const updateResult = updateResultRaw.rowCount;
addMarkSecondPass({
unmark: true,
organizationId: campaign.organization_id,
campaignId,
campaignTitle,
requesterId: user.id
});

return `Un-Marked ${updateResult} campaign contacts for a second pass.`;
return `Queuing ${contactsCount} campaign contacts to remove second pass marking. You'll receive an email when this is complete!`;
},

deleteNeedsMessage: async (_ignore, { campaignId }, { user }) => {
Expand Down
40 changes: 40 additions & 0 deletions src/server/lib/templates/mark-second-pass.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import ReactDOMServer from "react-dom/server";

import { config } from "../../../config";

export interface MarkSecondPassProps {
campaignId: number;
organizationId: number;
campaignTitle: string;
unmark?: boolean;
}

const MarkSecondPass: React.FC<MarkSecondPassProps> = ({
campaignId,
campaignTitle,
organizationId,
unmark
}) => {
const titleLink = (
<a
href={`${config.BASE_URL}/admin/${organizationId}/campaigns/${campaignId}`}
>
{campaignTitle}
</a>
);
return (
<>
<p>
Your second pass {unmark ? "un" : ""}marking for {titleLink} is
complete! Navigate back to the campaign here: {titleLink}
</p>
<p>-- The Spoke Rewired Team</p>
</>
);
};

export const getContent = async (props: MarkSecondPassProps) => {
const template = <MarkSecondPass {...props} />;
return ReactDOMServer.renderToStaticMarkup(template);
};
7 changes: 6 additions & 1 deletion src/server/tasks/chunk-tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
exportCampaign,
TASK_IDENTIFIER as EXPORT_CAMPAIGN_IDENTIFIER
} from "./export-campaign";
import {
markSecondPass,
TASK_IDENTIFIER as MARK_SECOND_PASS_IDENTIFIER
} from "./mark-second-pass";

export const taskList: TaskList = {
[EXPORT_CAMPAIGN_IDENTIFIER]: exportCampaign
[EXPORT_CAMPAIGN_IDENTIFIER]: exportCampaign,
[MARK_SECOND_PASS_IDENTIFIER]: markSecondPass
};
Loading

0 comments on commit d7a048b

Please sign in to comment.