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
148 changes: 30 additions & 118 deletions src/server/api/campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ 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 {
buildHasUnassignedContactsSubquery,
buildHasUnhandledRepliesSubquery,
buildHasUnsentInitialsSubquery,
getDeliverabilityStats,
invalidScriptFields
} from "./lib/campaign";
import { symmetricEncrypt } from "./lib/crypto";
import { getMessagingServiceById } from "./lib/message-sending";
import { formatPage } from "./lib/pagination";
Expand Down Expand Up @@ -125,6 +131,20 @@ const getCampaignOrganization = async ({ campaignId }) => {
return campaign.organization_id;
};

const checkCampaignContactsExist = async (campaign, buildSubquery) => {
if (
config.HIDE_CAMPAIGN_STATE_VARS_ON_ARCHIVED_CAMPAIGNS &&
campaign.is_archived
)
return false;

const { rows } = await r.reader.raw("SELECT EXISTS(?) as exists", [
buildSubquery(campaign.id).whereRaw(`archived = ${campaign.is_archived}`) // partial index friendly
]);

return rows[0].exists;
};

export const resolvers = {
JobRequest: {
...sqlResolvers([
Expand Down Expand Up @@ -586,124 +606,16 @@ export const resolvers = {
return row?.contacts_filename ?? null;
},
hasUnassignedContacts: async (campaign) => {
if (config.BAD_BENS_DISABLE_HAS_UNASSIGNED_CONTACTS) {
return false;
}

if (
config.HIDE_CAMPAIGN_STATE_VARS_ON_ARCHIVED_CAMPAIGNS &&
campaign.is_archived
) {
return false;
}

const getHasUnassignedContacts = async ({ campaignId, archived }) => {
// SQL injection for archived = to enable use of partial index
const { rows } = await r.reader.raw(
`
select exists (
select 1
from campaign_contact
where
campaign_id = ?
and assignment_id is null
and archived = ${archived}
and not exists (
select 1
from campaign_contact_tag
join tag on campaign_contact_tag.tag_id = tag.id
where tag.is_assignable = false
and campaign_contact_tag.campaign_contact_id = campaign_contact.id
)
and is_opted_out = false
) as contact_exists
`,
[campaignId]
);

return rows[0] && rows[0].contact_exists;
};

return getHasUnassignedContacts({
campaignId: campaign.id,
archived: campaign.is_archived
});
},
hasUnsentInitialMessages: async (campaign) => {
if (
config.HIDE_CAMPAIGN_STATE_VARS_ON_ARCHIVED_CAMPAIGNS &&
campaign.is_archived
) {
return false;
}

const getHasUnsentInitialMessages = async ({ campaignId, archived }) => {
const contacts = await r
.reader("campaign_contact")
.select("id")
.where({
campaign_id: campaignId,
message_status: "needsMessage",
is_opted_out: false
})
.whereRaw(`archived = ${archived}`) // partial index friendly
.limit(1);
return contacts.length > 0;
};

return getHasUnsentInitialMessages({
campaignId: campaign.id,
archived: campaign.is_archived
});
},
hasUnhandledMessages: async (campaign) => {
if (
config.HIDE_CAMPAIGN_STATE_VARS_ON_ARCHIVED_CAMPAIGNS &&
campaign.is_archived
) {
return false;
}

const getHasUnhandledMessages = async ({
campaignId,
archived,
organizationId
}) => {
let contactsQuery = r
.reader("campaign_contact")
.pluck("campaign_contact.id")
.where({
"campaign_contact.campaign_id": campaignId,
message_status: "needsResponse",
is_opted_out: false
})
.whereRaw(`archived = ${archived}`) // partial index friendly
.limit(1);

const notAssignableTagSubQuery = r.reader
.select("campaign_contact_tag.campaign_contact_id")
.from("campaign_contact_tag")
.join("tag", "tag.id", "=", "campaign_contact_tag.tag_id")
.where({
"tag.organization_id": organizationId
})
.whereRaw("lower(tag.title) = 'escalated'")
.whereRaw(
"campaign_contact_tag.campaign_contact_id = campaign_contact.id"
);

contactsQuery = contactsQuery.whereNotExists(notAssignableTagSubQuery);

const contacts = await contactsQuery;
return contacts.length > 0;
};

return getHasUnhandledMessages({
campaignId: campaign.id,
archived: campaign.is_archived,
organizationId: campaign.organization_id
});
if (config.BAD_BENS_DISABLE_HAS_UNASSIGNED_CONTACTS) return false;
return checkCampaignContactsExist(
campaign,
buildHasUnassignedContactsSubquery
);
},
hasUnsentInitialMessages: async (campaign) =>
checkCampaignContactsExist(campaign, buildHasUnsentInitialsSubquery),
hasUnhandledMessages: async (campaign) =>
checkCampaignContactsExist(campaign, buildHasUnhandledRepliesSubquery),
customFields: async (campaign) =>
campaign.customFields ||
cacheableData.campaign.dbCustomFields(campaign.id),
Expand Down
108 changes: 45 additions & 63 deletions src/server/api/lib/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CampaignInput,
CampaignsFilter
} from "@spoke/spoke-codegen";
import type { Knex } from "knex";
import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import isNil from "lodash/isNil";
Expand Down Expand Up @@ -49,6 +50,32 @@ type DoGetCampaigns = (
options: DoGetCampaignsOptions
) => Promise<RelayPaginatedResponse<Campaign>>;

const textableContacts = () =>
r.reader("campaign_contact").where({ is_opted_out: false });

// When campaignId is omitted the subquery is correlated to the outer campaign row.
const withCampaignFilter = (q: Knex.QueryBuilder, campaignId?: number) =>
campaignId
? q.where({ campaign_id: campaignId })
: q.whereRaw("campaign_id = campaign.id");

export const buildHasUnsentInitialsSubquery = (campaignId?: number) => {
const contacts = textableContacts().where({ message_status: "needsMessage" });
return withCampaignFilter(contacts, campaignId);
};

export const buildHasUnhandledRepliesSubquery = (campaignId?: number) => {
const contacts = textableContacts().where({
message_status: "needsResponse"
});
return withCampaignFilter(contacts, campaignId);
};

export const buildHasUnassignedContactsSubquery = (campaignId?: number) => {
const contacts = textableContacts().whereNull("assignment_id");
return withCampaignFilter(contacts, campaignId);
};

export const doGetCampaigns: DoGetCampaigns = async (
options: DoGetCampaignsOptions
) => {
Expand Down Expand Up @@ -90,71 +117,26 @@ export const doGetCampaigns: DoGetCampaigns = async (
]);
}

if (!isNil(hasUnsentInitialMessages)) {
const existsMethod = hasUnsentInitialMessages
? "whereExists"
: "whereNotExists";
query[existsMethod](
r
.reader("campaign_contact")
.select(r.reader.raw("1"))
.whereRaw('"campaign_contact"."campaign_id" = "campaign"."id"')
.whereRaw('"campaign_contact"."archived" = "campaign"."is_archived"')
.where({ message_status: "needsMessage", is_opted_out: false })
.limit(1)
);
}
const applySubqueryFilter = (
filter: boolean | null | undefined,
buildSubquery: () => Knex.QueryBuilder
) => {
if (isNil(filter)) return;

if (!isNil(hasUnhandledMessages)) {
const existsMethod = hasUnhandledMessages
? "whereExists"
: "whereNotExists";
query[existsMethod](
r
.reader("campaign_contact")
.select(r.reader.raw("1"))
.whereRaw('"campaign_contact"."campaign_id" = "campaign"."id"')
.whereRaw('"campaign_contact"."archived" = "campaign"."is_archived"')
.where({ message_status: "needsResponse", is_opted_out: false })
.whereNotExists(
r
.reader("campaign_contact_tag")
.join("tag", "campaign_contact_tag.tag_id", "tag.id")
.select(r.reader.raw("1"))
.whereRaw(
'"campaign_contact_tag"."campaign_contact_id" = "campaign_contact"."id"'
)
.whereRaw("lower(tag.title) = 'escalated'")
)
.limit(1)
);
}
const subquery = buildSubquery();
if (!isNil(isArchived))
subquery.whereRaw(`campaign_contact.archived = ${isArchived}`);

if (!isNil(hasUnassignedContacts)) {
const existsMethod = hasUnassignedContacts
? "whereExists"
: "whereNotExists";
query[existsMethod](
r
.reader("campaign_contact")
.select(r.reader.raw("1"))
.whereRaw('"campaign_contact"."campaign_id" = "campaign"."id"')
.whereRaw('"campaign_contact"."archived" = "campaign"."is_archived"')
.whereNull("campaign_contact.assignment_id")
.where({ is_opted_out: false })
.whereNotExists(
r
.reader("campaign_contact_tag")
.join("tag", "campaign_contact_tag.tag_id", "tag.id")
.select(r.reader.raw("1"))
.whereRaw(
'"campaign_contact_tag"."campaign_contact_id" = "campaign_contact"."id"'
)
.where({ "tag.is_assignable": false })
)
.limit(1)
);
}
if (filter) query.whereExists(subquery);
else query.whereNotExists(subquery);
};

applySubqueryFilter(hasUnsentInitialMessages, buildHasUnsentInitialsSubquery);
applySubqueryFilter(hasUnhandledMessages, buildHasUnhandledRepliesSubquery);
applySubqueryFilter(
hasUnassignedContacts,
buildHasUnassignedContactsSubquery
);

const pagerOptions = { first, after };
return formatPage(query, pagerOptions);
Expand Down
Loading