diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index a1526a757..4e5a4754d 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -215,6 +215,11 @@ const rootSchema = ` vanOptions: ExportForVanInput } + input MultipleCampaignExportInput { + campaignIds: [String!]! + spokeOptions: ExportForSpokeInput! + } + input QuestionResponseSyncConfigInput { id: String! } @@ -287,6 +292,7 @@ const rootSchema = ` copyCampaign(id: String!): Campaign copyCampaigns(sourceCampaignId: String!, quantity: Int!, targetOrgId: String): [Campaign!]! exportCampaign(options: CampaignExportInput!): JobRequest + exportCampaigns(options: MultipleCampaignExportInput!): [JobRequest] createCannedResponse(cannedResponse:CannedResponseInput!): CannedResponse createOrganization(name: String!, userId: String!, inviteId: String!): Organization editOrganization(id: String! input: EditOrganizationInput!): Organization! diff --git a/libs/spoke-codegen/src/graphql/campaign-stats.graphql b/libs/spoke-codegen/src/graphql/campaign-stats.graphql index 9576c7187..746cbd438 100644 --- a/libs/spoke-codegen/src/graphql/campaign-stats.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-stats.graphql @@ -63,8 +63,22 @@ mutation ExportCampaign($options: CampaignExportInput!) { } } -mutation CopyCampaigns($templateId: String!, $quantity: Int!, $targetOrgId: String) { - copyCampaigns(sourceCampaignId: $templateId, quantity: $quantity, targetOrgId: $targetOrgId) { +mutation ExportCampaigns($options: MultipleCampaignExportInput!) { + exportCampaigns(options: $options) { + id + } +} + +mutation CopyCampaigns( + $templateId: String! + $quantity: Int! + $targetOrgId: String +) { + copyCampaigns( + sourceCampaignId: $templateId + quantity: $quantity + targetOrgId: $targetOrgId + ) { id } } @@ -82,7 +96,6 @@ query GetCampaignSyncConfigs($campaignId: String!) { } } - query GetSyncTargets($campaignId: String!) { campaign(id: $campaignId) { id diff --git a/src/components/ExportCampaignDataSnackbar.tsx b/src/components/ExportCampaignDataSnackbar.tsx new file mode 100644 index 000000000..3aaabb9b7 --- /dev/null +++ b/src/components/ExportCampaignDataSnackbar.tsx @@ -0,0 +1,30 @@ +import Snackbar from "@material-ui/core/Snackbar"; +import Alert from "@material-ui/lab/Alert"; +import React from "react"; + +interface Props { + open: boolean; + errorMessage: string | null; + onClose: () => void; +} + +const ExportCampaignDataSnackbar: React.FC = ({ + open, + errorMessage, + onClose +}) => { + return errorMessage ? ( + + {errorMessage} + + ) : ( + + ); +}; + +export default ExportCampaignDataSnackbar; diff --git a/src/components/ExportMultipleCampaignDataDialog.tsx b/src/components/ExportMultipleCampaignDataDialog.tsx new file mode 100644 index 000000000..4e90ac443 --- /dev/null +++ b/src/components/ExportMultipleCampaignDataDialog.tsx @@ -0,0 +1,140 @@ +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Divider from "@material-ui/core/Divider"; +import Typography from "@material-ui/core/Typography"; +import { useExportCampaignsMutation } from "@spoke/spoke-codegen"; +import React, { useState } from "react"; + +import { CampaignExportModalContent } from "../containers/AdminCampaignStats/components/CampaignExportModal"; + +export type CampaignDetailsForExport = { + id: string; + title: string; +}; +interface Props { + campaignDetailsForExport: CampaignDetailsForExport[]; + open: boolean; + onClose: () => void; + onError: (errorMessage: string) => void; + onComplete(): void; +} + +const ExportMultipleCampaignDataDialog: React.FC = ({ + campaignDetailsForExport, + open, + onClose, + onError, + onComplete +}) => { + const [exportCampaign, setExportCampaign] = useState(true); + const [exportMessages, setExportMessages] = useState(true); + const [exportOptOut, setExportOptOut] = useState(false); + const [exportFiltered, setExportFiltered] = useState(false); + + const [exportCampaignsMutation] = useExportCampaignsMutation(); + + const handleChange = ( + setStateFunction: React.Dispatch> + ) => (event: React.ChangeEvent) => { + setStateFunction(event.target.checked); + }; + + const handleExportClick = async () => { + const campaignIds = campaignDetailsForExport.map( + (campaign: CampaignDetailsForExport) => campaign.id + ); + const result = await exportCampaignsMutation({ + variables: { + options: { + campaignIds, + spokeOptions: { + campaign: exportCampaign, + messages: exportMessages, + optOuts: exportOptOut, + filteredContacts: exportFiltered + } + } + } + }); + if (result.errors) { + const message = result.errors.map((e) => e.message).join(", "); + return onError(message); + } + onComplete(); + }; + + return ( + + + + Export Campaigns + + + + + + + + Selected campaigns: + + {campaignDetailsForExport.map( + (campaign: CampaignDetailsForExport) => { + return ( +
+ + {campaign.title} + + + ID: {campaign.id} + +
+ ); + } + )} +
+
+ + + + +
+ ); +}; + +export default ExportMultipleCampaignDataDialog; diff --git a/src/containers/AdminCampaignList.jsx b/src/containers/AdminCampaignList.jsx index be0f6cd3e..72f1e99fa 100644 --- a/src/containers/AdminCampaignList.jsx +++ b/src/containers/AdminCampaignList.jsx @@ -4,6 +4,7 @@ import Dialog from "@material-ui/core/Dialog"; import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; import CreateIcon from "@material-ui/icons/Create"; import FileCopyIcon from "@material-ui/icons/FileCopyOutlined"; import SpeedDial from "@material-ui/lab/SpeedDial"; @@ -18,6 +19,8 @@ import { withRouter } from "react-router-dom"; import { compose } from "recompose"; import CreateCampaignFromTemplateDialog from "../components/CreateCampaignFromTemplateDialog"; +import ExportCampaignDataSnackbar from "../components/ExportCampaignDataSnackbar"; +import ExportMultipleCampaignDataDialog from "../components/ExportMultipleCampaignDataDialog"; import LoadingIndicator from "../components/LoadingIndicator"; import theme from "../styles/theme"; import { withAuthzContext } from "./AuthzProvider"; @@ -30,6 +33,10 @@ const styles = { alignItems: "baseline", justifyContent: "space-between", padding: 5 + }, + filterWrapper: { + display: "flex", + alignIems: "baseline" } }; @@ -42,12 +49,17 @@ class AdminCampaignList extends React.Component { createFromTemplateOpen: false, isCreating: false, campaignsFilter: { - isArchived: false + isArchived: false, + campaignTitle: "" }, releasingInProgress: false, releasingAllReplies: false, releaseAllRepliesError: undefined, - releaseAllRepliesResult: undefined + releaseAllRepliesResult: undefined, + campaignDetailsForExport: [], + showExportModal: false, + showExportSnackbar: false, + exportErrorMessage: null }; handleClickNewButton = async () => { @@ -74,10 +86,22 @@ class AdminCampaignList extends React.Component { ); }; - handleFilterChange = (event, index, value) => { + handleFilterChangeCurrentOrArchived = (_event, _index, value) => { + const { campaignTitle } = this.state.campaignsFilter; this.setState({ campaignsFilter: { - isArchived: value + isArchived: value, + campaignTitle + } + }); + }; + + handleFilterCampaignTitle = (campaignTitle) => { + const { isArchived } = this.state.campaignsFilter; + this.setState({ + campaignsFilter: { + isArchived, + campaignTitle } }); }; @@ -133,11 +157,41 @@ class AdminCampaignList extends React.Component { }); }; - renderFilters() { + // handle selecting and de-selecting campaigns via CampaignListMenu + handleSelectForExport = (incomingCampaign) => { + const { campaignDetailsForExport: currentDetails } = this.state; + const currentIds = currentDetails.map((campaign) => campaign.id); + const isDeSelecting = currentIds.includes(incomingCampaign.id); + if (isDeSelecting) { + const filteredCampaigns = currentDetails.filter( + (campaign) => campaign.id !== incomingCampaign.id + ); + return this.setState({ campaignDetailsForExport: filteredCampaigns }); + } + return this.setState({ + campaignDetailsForExport: currentDetails.concat(incomingCampaign) + }); + }; + + handleClickExportButton = () => { + this.setState({ + showExportModal: true + }); + }; + + handleErrorCampaignExport = (exportErrorMessage) => { + this.setState({ + exportErrorMessage, + showExportModal: false, + showExportSnackbar: true + }); + }; + + renderCurrentCampaignFilter() { return ( @@ -149,7 +203,11 @@ class AdminCampaignList extends React.Component { const { campaignsFilter, releasingAllReplies, - releasingInProgress + releasingInProgress, + campaignDetailsForExport, + showExportModal, + showExportSnackbar, + exportErrorMessage } = this.state; const doneReleasingReplies = @@ -160,7 +218,12 @@ class AdminCampaignList extends React.Component { return (
- {this.renderFilters()} +
+ + Campaigns + + {this.renderCurrentCampaignFilter()} +
); } diff --git a/src/containers/AdminCampaignStats/components/CampaignExportModal.tsx b/src/containers/AdminCampaignStats/components/CampaignExportModal.tsx index 1b3cf4457..1d7bf29f5 100644 --- a/src/containers/AdminCampaignStats/components/CampaignExportModal.tsx +++ b/src/containers/AdminCampaignStats/components/CampaignExportModal.tsx @@ -19,6 +19,79 @@ interface CampaignExportModalProps { onComplete(): void; } +interface CampaignExportModalContentProps { + exportCampaign: boolean; + exportMessages: boolean; + exportOptOut: boolean; + exportFiltered: boolean; + handleChange: ( + setStateFunction: any + ) => (event: React.ChangeEvent) => void; + setExportCampaign: React.Dispatch>; + setExportMessages: React.Dispatch>; + setExportOptOut: React.Dispatch>; + setExportFiltered: React.Dispatch>; +} +// eslint-disable-next-line max-len +export const CampaignExportModalContent: React.FC = ({ + exportCampaign, + exportMessages, + exportOptOut, + exportFiltered, + handleChange, + setExportCampaign, + setExportMessages, + setExportOptOut, + setExportFiltered +}) => { + return ( + +
+ + } + /> +
+
+ + } + /> +
+ + } + /> +
+ + } + /> +
+
+ ); +}; + const CampaignExportModal: React.FC = (props) => { const { campaignId, open, onClose, onComplete, onError } = props; const [exportCampaign, setExportCampaign] = useState(true); @@ -60,50 +133,17 @@ const CampaignExportModal: React.FC = (props) => { return ( Export Campaign - -
- - } - /> -
-
- - } - /> -
- - } - /> -
- - } - /> -
-
+ + + + + ) + }} + onChange={(ev) => debounceSearchTerm(ev.target.value)} + /> +
+ ); +}; + +export default CampaignListHeader; diff --git a/src/containers/CampaignList/components/CampaignListLoader.tsx b/src/containers/CampaignList/components/CampaignListLoader.tsx index 3cd664528..b4b4cca22 100644 --- a/src/containers/CampaignList/components/CampaignListLoader.tsx +++ b/src/containers/CampaignList/components/CampaignListLoader.tsx @@ -7,19 +7,19 @@ import type { import { useGetAdminCampaignsQuery } from "@spoke/spoke-codegen"; import React from "react"; +import type { CampaignDetailsForExport } from "../../../components/ExportMultipleCampaignDataDialog"; import LoadingIndicator from "../../../components/LoadingIndicator"; import { useAuthzContext } from "../../AuthzProvider"; import { isCampaignGroupsPermissionError } from "../utils"; import CampaignList from "./CampaignList"; +import type { CampaignOperations } from "./CampaignListMenu"; -interface Props { +interface Props extends CampaignOperations { organizationId: string; pageSize: number; campaignsFilter: CampaignsFilter; isAdmin: boolean; - startOperation: (...args: any[]) => any; - archiveCampaign: (...args: any[]) => any; - unarchiveCampaign: (...args: any[]) => any; + campaignDetailsForExport: CampaignDetailsForExport[]; } const CampaignListLoader: React.FC = (props) => { @@ -30,7 +30,9 @@ const CampaignListLoader: React.FC = (props) => { isAdmin, startOperation, archiveCampaign, - unarchiveCampaign + unarchiveCampaign, + selectForExport, + campaignDetailsForExport } = props; const { data, loading, error, fetchMore } = useGetAdminCampaignsQuery({ variables: { organizationId, limit: pageSize, filter: campaignsFilter }, @@ -105,6 +107,8 @@ const CampaignListLoader: React.FC = (props) => { startOperation={startOperation} archiveCampaign={archiveCampaign} unarchiveCampaign={unarchiveCampaign} + selectForExport={selectForExport} + campaignDetailsForExport={campaignDetailsForExport} /> {loading && }
diff --git a/src/containers/CampaignList/components/CampaignListMenu.tsx b/src/containers/CampaignList/components/CampaignListMenu.tsx index 68f7d1d51..1fdadb2c8 100644 --- a/src/containers/CampaignList/components/CampaignListMenu.tsx +++ b/src/containers/CampaignList/components/CampaignListMenu.tsx @@ -19,7 +19,6 @@ export interface CampaignOperations { archiveCampaign: (campaignId: string) => ClickHandler; unarchiveCampaign: (campaignId: string) => ClickHandler; } - interface Props extends CampaignOperations { campaign: CampaignListEntryFragment; } diff --git a/src/containers/CampaignList/components/CampaignListRow.tsx b/src/containers/CampaignList/components/CampaignListRow.tsx index f6c84ed54..8b2b413b1 100644 --- a/src/containers/CampaignList/components/CampaignListRow.tsx +++ b/src/containers/CampaignList/components/CampaignListRow.tsx @@ -1,46 +1,38 @@ -import Avatar from "@material-ui/core/Avatar"; -import Chip from "@material-ui/core/Chip"; -import blue from "@material-ui/core/colors/blue"; +import { Card } from "@material-ui/core"; +import Checkbox from "@material-ui/core/Checkbox"; import ListItem from "@material-ui/core/ListItem"; -import ListItemAvatar from "@material-ui/core/ListItemAvatar"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; import ListItemText from "@material-ui/core/ListItemText"; -import { useTheme } from "@material-ui/core/styles"; -import WarningIcon from "@material-ui/icons/Warning"; import type { CampaignListEntryFragment } from "@spoke/spoke-codegen"; import React from "react"; import { useHistory } from "react-router-dom"; +import type { CampaignDetailsForExport } from "../../../components/ExportMultipleCampaignDataDialog"; import { dataTest } from "../../../lib/attributes"; -import { DateTime } from "../../../lib/datetime"; +import { makeCampaignHeaderTags } from "../utils"; +import CampaignDetails from "./CampaignDetails"; +import CampaignHeader from "./CampaignHeader"; import type { CampaignOperations } from "./CampaignListMenu"; import CampaignListMenu from "./CampaignListMenu"; -const inlineStyles = { - chipWrapper: { - display: "flex", - flexWrap: "wrap", - alignItems: "center" - }, - chip: { margin: "4px" }, - past: { - opacity: 0.6 - }, - secondaryText: { - whiteSpace: "pre-wrap" - } -}; - interface Props extends CampaignOperations { organizationId: string; isAdmin: boolean; campaign: CampaignListEntryFragment; + campaignDetailsForExport: CampaignDetailsForExport[]; + selectForExport: (details: CampaignDetailsForExport) => void; } export const CampaignListRow: React.FC = (props) => { - const theme = useTheme(); const history = useHistory(); - const { organizationId, isAdmin, campaign } = props; + const { + organizationId, + isAdmin, + campaign, + campaignDetailsForExport, + selectForExport + } = props; const { isStarted, isArchived, @@ -53,126 +45,81 @@ export const CampaignListRow: React.FC = (props) => { externalSystem } = campaign; - let listItemStyle = {}; - let leftIcon; - if (isArchived) { - listItemStyle = inlineStyles.past; - } else if (!isStarted || hasUnassignedContacts) { - listItemStyle = { - color: theme.palette.warning.dark - }; - leftIcon = ; - } else if (hasUnsentInitialMessages) { - listItemStyle = { - color: theme.palette.info.dark - }; - } else { - listItemStyle = { - color: theme.palette.success.dark - }; - } - const dueBy = DateTime.fromISO(campaign.dueBy || ""); const creatorName = campaign.creator ? campaign.creator.displayName : null; - let tags = []; - if (DateTime.local() >= dueBy) { - tags.push({ - title: "Overdue", - color: theme.palette.grey[900], - backgroundColor: theme.palette.error.main - }); - } - - if (externalSystem) { - const title = `${externalSystem.type}: ${externalSystem.name}`; - tags.push({ title, backgroundColor: blue[300] }); - } - - if (!isStarted) { - tags.push({ title: "Not started" }); - } - - if (hasUnassignedContacts) { - tags.push({ title: "Unassigned contacts" }); - } - - if (isStarted && hasUnsentInitialMessages) { - tags.push({ title: "Unsent initial messages" }); - } - - if (isStarted && hasUnhandledMessages) { - tags.push({ title: "Unhandled replies" }); - } - - if (isStarted && !isArchived && isAutoassignEnabled) { - tags.push({ title: "Autoassign eligible" }); - } - tags = tags.concat(teams.map(({ title }) => ({ title }))); - if (campaignGroups) { - tags = tags.concat( - campaignGroups.edges.map(({ node }) => ({ title: node.name })) - ); - } + const headerTags = makeCampaignHeaderTags({ + isStarted, + hasUnassignedContacts, + hasUnsentInitialMessages, + hasUnhandledMessages + }); - const primaryText = ( -
- {campaign.title} - {tags.map((tag) => ( - - ))} -
- ); - const secondaryText = ( - - - Campaign ID: {campaign.id} -
- {campaign.description} - {creatorName ? — Created by {creatorName} : null} -
- {dueBy.isValid ? dueBy.toFormat("DD") : "No due date set"} -
-
+ const isCampaignSelected = !!campaignDetailsForExport.find( + (selectedCampaign: CampaignDetailsForExport) => + selectedCampaign.id === campaign.id ); const campaignUrl = `/admin/${organizationId}/campaigns/${campaign.id}${ isStarted ? "" : "/edit" }`; + + // satisfy typescript (boolean | null | undefined possible for these vars) + const isAutoAssignEligible = !!( + isStarted && + !isArchived && + isAutoassignEnabled + ); return ( - history.push(campaignUrl)} + - {leftIcon && ( - - {leftIcon} - - )} - - {isAdmin && ( - - + + + selectForExport({ id: campaign.id, title: campaign.title }) + } /> - - )} - + + history.push(campaignUrl)} + /> + } + secondary={ + + } + secondaryTypographyProps={{ color: "textPrimary" }} + /> + {isAdmin && ( + + + + )} + + ); }; diff --git a/src/containers/CampaignList/index.jsx b/src/containers/CampaignList/index.jsx index ed24c6a24..9897579b9 100644 --- a/src/containers/CampaignList/index.jsx +++ b/src/containers/CampaignList/index.jsx @@ -4,6 +4,7 @@ import React from "react"; import { loadData } from "../hoc/with-operations"; import AssignmentHUD from "./components/AssignmentHUD"; +import CampaignListHeader from "./components/CampaignListHeader"; import CampaignListLoader from "./components/CampaignListLoader"; import { OperationDialog, operations } from "./components/OperationDialog"; @@ -50,7 +51,11 @@ export class CampaignList extends React.Component { campaignsFilter, isAdmin, data, - mutations + mutations, + selectForExport, + campaignDetailsForExport, + filterByCampaignTitle, + handleClickExportButton } = this.props; const { currentAssignmentTargets } = data.organization; const { archiveCampaign, unarchiveCampaign } = mutations; @@ -69,6 +74,11 @@ export class CampaignList extends React.Component { /> )} +
); @@ -88,6 +100,10 @@ CampaignList.propTypes = { campaignsFilter: PropTypes.object.isRequired, pageSize: PropTypes.number.isRequired, isAdmin: PropTypes.bool.isRequired, + campaignDetailsForExport: PropTypes.array.isRequired, + filterByCampaignTitle: PropTypes.func.isRequired, + selectForExport: PropTypes.func.isRequired, + handleClickExportButton: PropTypes.func.isRequired, data: PropTypes.object.isRequired, mutations: PropTypes.object.isRequired }; diff --git a/src/containers/CampaignList/utils.ts b/src/containers/CampaignList/utils.ts index 2310fc1d1..ef0f227ac 100644 --- a/src/containers/CampaignList/utils.ts +++ b/src/containers/CampaignList/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable import/prefer-default-export */ import type { GraphQLError } from "graphql"; +import type { Tag } from "./components/CampaignHeader"; + export const isCampaignGroupsPermissionError = (gqlError: GraphQLError) => { return ( gqlError.path && @@ -8,3 +10,72 @@ export const isCampaignGroupsPermissionError = (gqlError: GraphQLError) => { gqlError.extensions.code === "FORBIDDEN" ); }; + +type MakeCampaignTagsFn = (props: { + isStarted: boolean | null | undefined; + hasUnassignedContacts: boolean | null | undefined; + hasUnsentInitialMessages: boolean | null | undefined; + hasUnhandledMessages: boolean | null | undefined; +}) => Tag[]; + +export const makeCampaignHeaderTags: MakeCampaignTagsFn = ({ + isStarted, + hasUnassignedContacts, + hasUnsentInitialMessages, + hasUnhandledMessages +}) => { + const tags = []; + + // display 'Started' or 'Not Started' first + if (isStarted) { + tags.push({ + title: "Started", + status: "success" + }); + } else { + tags.push({ + title: "Not Started", + status: "alert" + }); + } + + if (hasUnassignedContacts) { + tags.push({ + title: "Unassigned Contacts", + status: "alert" + }); + } else { + tags.push({ + title: "All Contacts Assigned", + status: "success" + }); + } + + if (isStarted) { + const tag = hasUnsentInitialMessages + ? { + title: "Unsent Initial Messages", + status: "alert" + } + : { + title: "All Initials Sent", + status: "success" + }; + tags.push(tag); + } + + if (isStarted && hasUnhandledMessages) { + const tag = hasUnhandledMessages + ? { + title: "Unhandled Replies", + status: "alert" + } + : { + title: "All Replies Handled", + status: "success" + }; + tags.push(tag); + } + + return tags; +}; diff --git a/src/schema.graphql b/src/schema.graphql index cdee7ef2d..69f9bf1f0 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -181,6 +181,11 @@ input CampaignExportInput { vanOptions: ExportForVanInput } + input MultipleCampaignExportInput { + campaignIds: [String!]! + spokeOptions: ExportForSpokeInput! +} + input QuestionResponseSyncConfigInput { id: String! } @@ -253,6 +258,7 @@ type RootMutation { copyCampaign(id: String!): Campaign copyCampaigns(sourceCampaignId: String!, quantity: Int!, targetOrgId: String): [Campaign!]! exportCampaign(options: CampaignExportInput!): JobRequest + exportCampaigns(options: MultipleCampaignExportInput!): [JobRequest] createCannedResponse(cannedResponse:CannedResponseInput!): CannedResponse createOrganization(name: String!, userId: String!, inviteId: String!): Organization editOrganization(id: String! input: EditOrganizationInput!): Organization! diff --git a/src/server/api/export-multiple-campaigns.ts b/src/server/api/export-multiple-campaigns.ts new file mode 100644 index 000000000..be727d0eb --- /dev/null +++ b/src/server/api/export-multiple-campaigns.ts @@ -0,0 +1,10 @@ +import type { CampaignExportMetaData } from "../lib/templates/export-multiple-campaigns"; +import getEmailContent from "../lib/templates/export-multiple-campaigns"; + +const formatMultipleCampaignExportsEmail = ( + campaignExportMetaData: CampaignExportMetaData +) => { + return getEmailContent(campaignExportMetaData); +}; + +export default formatMultipleCampaignExportsEmail; diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 4bf184370..740288548 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -52,7 +52,14 @@ type DoGetCampaigns = ( export const doGetCampaigns: DoGetCampaigns = async ( options: DoGetCampaignsOptions ) => { - const { after, first, organizationId, campaignId, isArchived } = options; + const { + after, + first, + organizationId, + campaignId, + isArchived, + campaignTitle + } = options; const query = r.reader("campaign").select("*"); @@ -69,6 +76,12 @@ export const doGetCampaigns: DoGetCampaigns = async ( query.where({ is_archived: isArchived }); } + if (campaignTitle) { + query.whereRaw(`concat("id", ': ', "title") ilike ?`, [ + `%${campaignTitle}%` + ]); + } + const pagerOptions = { first, after }; return formatPage(query, pagerOptions); }; diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index 50396e08b..0ca41010d 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -32,6 +32,7 @@ import { getUserById } from "../models/cacheable_queries"; import { Notifications, sendUserNotification } from "../notifications"; import { addExportCampaign } from "../tasks/export-campaign"; import { addExportForVan } from "../tasks/export-for-van"; +import { addExportMultipleCampaigns } from "../tasks/export-multiple-campaigns"; import { TASK_IDENTIFIER as exportOptOutsIdentifier } from "../tasks/export-opt-outs"; import { addFilterLandlines } from "../tasks/filter-landlines"; import { QUEUE_AUTOSEND_ORGANIZATION_INITIALS_TASK_IDENTIFIER } from "../tasks/queue-autosend-initials"; @@ -327,6 +328,23 @@ const rootMutations = { } }, + exportCampaigns: async (_root, { options }, { user, loaders }) => { + const { campaignIds, spokeOptions } = options; + + if (!spokeOptions) { + throw new Error("Input must include valid spokeOptions when exporting"); + } + const campaignId = campaignIds[0]; + const campaign = await loaders.campaign.load(campaignId); + const organizationId = campaign.organization_id; + await accessRequired(user, organizationId, "ADMIN"); + return addExportMultipleCampaigns({ + campaignIds, + requesterId: user.id, + spokeOptions + }); + }, + editOrganizationMembership: async ( _root, { id, level, role }, diff --git a/src/server/lib/templates/export-campaign.tsx b/src/server/lib/templates/export-campaign.tsx index f5b2a9752..7a0e360e5 100644 --- a/src/server/lib/templates/export-campaign.tsx +++ b/src/server/lib/templates/export-campaign.tsx @@ -7,13 +7,12 @@ export type ExportURLs = { campaignOptOutsExportUrl?: string | undefined; campaignFilteredContactsExportUrl?: string | undefined; }; - interface ExportProps { exportUrls: ExportURLs; campaignTitle: string; } -const ExportCampaign: React.FC = ({ +export const ExportCampaign: React.FC = ({ exportUrls, campaignTitle }) => { diff --git a/src/server/lib/templates/export-multiple-campaigns.tsx b/src/server/lib/templates/export-multiple-campaigns.tsx new file mode 100644 index 000000000..a8aece466 --- /dev/null +++ b/src/server/lib/templates/export-multiple-campaigns.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import ReactDOMServer from "react-dom/server"; + +import type { ExportURLs } from "./export-campaign"; + +export type CampaignExportDetails = { + campaignTitle: string; + exportUrls: ExportURLs | null; +}; + +export type CampaignExportMetaData = { + [id: string]: CampaignExportDetails; +}; + +interface Props { + campaignExportMetaData: CampaignExportMetaData; +} + +const ExportMultipleCampaigns: React.FC = ({ + campaignExportMetaData +}) => { + const campaignIds = Object.keys(campaignExportMetaData); + return ( +
+

+ Your spoke exports are ready! These URLs will be valid for 24 hours. +

+ {campaignIds.map((campaignId) => { + const { exportUrls, campaignTitle } = campaignExportMetaData[ + campaignId + ]; + // this is checked before rendering the component, but satisfying typescript here + if (!exportUrls) { + throw new Error( + "attempted to render email component without export urls" + ); + } + const { + campaignExportUrl, + campaignMessagesExportUrl, + campaignOptOutsExportUrl, + campaignFilteredContactsExportUrl + } = exportUrls; + return ( +
+

+ {campaignTitle} - ID: {campaignId} +

+ {campaignExportUrl &&

Campaign Export: {campaignExportUrl}

} + {campaignMessagesExportUrl && ( +

Campaign Messages Export: {campaignMessagesExportUrl}

+ )} + {campaignOptOutsExportUrl && ( +

Campaign OptOuts Export: {campaignOptOutsExportUrl}

+ )} + {campaignFilteredContactsExportUrl && ( +

+ Campaign Filtered Contacts Export:{" "} + {campaignFilteredContactsExportUrl} +

+ )} +
+ ); + })} +
+ -- The Spoke Rewired Team +
+ ); +}; + +const getEmailContent = (campaignExportMetaData: CampaignExportMetaData) => { + const template = ( + + ); + + return ReactDOMServer.renderToStaticMarkup(template); +}; + +export default getEmailContent; diff --git a/src/server/tasks/export-campaign.ts b/src/server/tasks/export-campaign.ts index 90a915116..a1e3a2a22 100644 --- a/src/server/tasks/export-campaign.ts +++ b/src/server/tasks/export-campaign.ts @@ -387,7 +387,7 @@ export const processMessagesChunk = async ( interface UploadCampaignContacts { campaignTitle: string; - interactionSteps: Array; + interactionSteps: InteractionStepRecord[]; contactsCount: number; campaignId: number; helpers: ProgressTaskHelpers; @@ -627,49 +627,28 @@ const processAndUploadFilteredContacts = async ({ return getDownloadUrl(`${filteredContactsKey}.csv`); }; -export interface ExportCampaignPayload { +export interface SpokeOptions { + campaign: boolean; + messages: boolean; + optOuts: boolean; + filteredContacts: boolean; +} + +export interface CampaignDataForExport { + fileNameKey: string; campaignId: number; - requesterId: number; - isAutomatedExport?: boolean; - spokeOptions: { - campaign: boolean; - messages: boolean; - optOuts: boolean; - filteredContacts: boolean; - }; + campaignTitle: string; + contactsCount: number; + helpers: ProgressTaskHelpers; + interactionSteps: InteractionStepRecord[]; + campaignVariableNames: string[]; } -export const exportCampaign: ProgressTask = async ( - payload, - helpers +// kicks off export processes and returns url for email +export const processExportData = async ( + campaignData: CampaignDataForExport, + spokeOptions: SpokeOptions ) => { - const { - campaignId, - requesterId, - isAutomatedExport = false, - spokeOptions - } = payload; - const { - campaignTitle, - notificationEmail, - interactionSteps, - campaignVariableNames - } = await fetchExportData(campaignId, requesterId); - - const countQueryResult = await r - .reader("campaign_contact") - .count("*") - .where({ campaign_id: campaignId }); - const contactsCount = countQueryResult[0].count as number; - - // Attempt upload to cloud storage - let fileNameKey = campaignTitle.replace(/ /g, "_").replace(/\//g, "_"); - - if (!isAutomatedExport) { - const timestamp = DateTime.local().toFormat("y-mm-d-hh-mm-ss"); - fileNameKey = `${fileNameKey}-${timestamp}`; - } - const { campaign: shouldExportCampaign, filteredContacts: shouldExportFilteredContacts, @@ -677,6 +656,16 @@ export const exportCampaign: ProgressTask = async ( optOuts: shouldExportOptOuts } = spokeOptions; + const { + fileNameKey, + campaignId, + campaignTitle, + contactsCount, + helpers, + interactionSteps, + campaignVariableNames + } = campaignData; + const campaignExportUrl = shouldExportCampaign ? await processAndUploadCampaignContacts({ fileNameKey, @@ -720,6 +709,69 @@ export const exportCampaign: ProgressTask = async ( }) : null; + return { + campaignExportUrl, + campaignFilteredContactsExportUrl, + campaignOptOutsExportUrl, + campaignMessagesExportUrl + }; +}; + +export interface ExportCampaignPayload { + campaignId: number; + requesterId: number; + isAutomatedExport?: boolean; + spokeOptions: SpokeOptions; +} + +export const exportCampaign: ProgressTask = async ( + payload, + helpers +) => { + const { + campaignId, + requesterId, + isAutomatedExport = false, + spokeOptions + } = payload; + const { + campaignTitle, + notificationEmail, + interactionSteps, + campaignVariableNames + } = await fetchExportData(campaignId, requesterId); + + const countQueryResult = await r + .reader("campaign_contact") + .count("*") + .where({ campaign_id: campaignId }); + const contactsCount = countQueryResult[0].count as number; + + // Attempt upload to cloud storage + let fileNameKey = campaignTitle.replace(/ /g, "_").replace(/\//g, "_"); + + if (!isAutomatedExport) { + const timestamp = DateTime.local().toFormat("y-mm-d-hh-mm-ss"); + fileNameKey = `${fileNameKey}-${timestamp}`; + } + + const campaignMetaData = { + fileNameKey, + campaignId, + campaignTitle, + contactsCount, + helpers, + interactionSteps, + campaignVariableNames + }; + + const { + campaignExportUrl, + campaignFilteredContactsExportUrl, + campaignOptOutsExportUrl, + campaignMessagesExportUrl + } = await processExportData(campaignMetaData, spokeOptions); + helpers.logger.debug("Waiting for streams to finish"); try { diff --git a/src/server/tasks/export-multiple-campaigns.ts b/src/server/tasks/export-multiple-campaigns.ts new file mode 100644 index 000000000..21aaf7070 --- /dev/null +++ b/src/server/tasks/export-multiple-campaigns.ts @@ -0,0 +1,206 @@ +import DateTime from "../../lib/datetime"; +import formatMultipleCampaignExportsEmail from "../api/export-multiple-campaigns"; +import type { JobRequestRecord } from "../api/types"; +import type { CampaignExportMetaData } from "../lib/templates/export-multiple-campaigns"; +import sendEmail from "../mail"; +import { r } from "../models"; +import type { ExportCampaignPayload } from "./export-campaign"; +import { fetchExportData, processExportData } from "./export-campaign"; +import type { ProgressTask } from "./utils"; +import { addProgressJob } from "./utils"; + +export const EXPORT_TASK_IDENTIFIER = "bulk-export-campaigns"; +export const EMAIL_TASK_IDENTIFIER = "email-export-campaigns"; + +export interface ExportMultipleCampaignsPayload { + campaignIds: [number]; + requesterId: number; + spokeOptions: { + campaign: boolean; + messages: boolean; + optOuts: boolean; + filteredContacts: boolean; + }; +} + +// eslint-disable-next-line max-len +export const exportCampaignForBulkOperation: ProgressTask = async ( + payload, + helpers +) => { + const { campaignId, requesterId, spokeOptions } = payload; + + const { + campaignTitle, + interactionSteps, + campaignVariableNames + } = await fetchExportData(campaignId, requesterId); + + const [{ count }] = await r + .reader("campaign_contact") + .count("*") + .where({ campaign_id: campaignId }); + const contactsCount = typeof count === "number" ? count : parseInt(count, 10); + + // Attempt upload to cloud storage + const fileNameKey = campaignTitle.replace(/ /g, "_").replace(/\//g, "_"); + const timestamp = DateTime.local().toFormat("y-mm-d-hh-mm-ss"); + const keyWithTimeStamp = `${fileNameKey}-${timestamp}`; + + const campaignMetaData = { + fileNameKey: keyWithTimeStamp, + campaignId, + campaignTitle, + contactsCount, + helpers, + interactionSteps, + campaignVariableNames + }; + + const exportUrls = await processExportData(campaignMetaData, spokeOptions); + helpers.logger.info( + `Exported data for campaign: ${campaignTitle} - ID ${campaignId}` + ); + + // store exportUrls as JSON string in job_request table + await helpers.updateResult({ exportUrls: JSON.stringify(exportUrls) }); + // indicate that the exports have been processed + await helpers.updateStatus(100); +}; + +type EmailBulkOperationPayload = ExportCampaignPayload & { + jobRequestRecords: JobRequestRecord[]; + campaignIds: string[]; +}; + +// eslint-disable-next-line max-len +export const sendEmailForBulkExportOperation: ProgressTask = async ( + payload, + helpers +) => { + const { jobRequestRecords, campaignId, requesterId, campaignIds } = payload; + + // map campaign id to campaign title and export urls for email composition + const campaignMetaDataMap: CampaignExportMetaData = {}; + for (const id of campaignIds) { + const parsed = parseInt(id, 10); + const { campaignTitle } = await fetchExportData(parsed, requesterId); + campaignMetaDataMap[id] = { campaignTitle, exportUrls: null }; + } + + const jobRequestIds = jobRequestRecords.map((record) => record.id); + // query job_req table for campaign export urls where status is 100 (exports complete) + const { rows } = await helpers.query( + ` + select campaign_id, result_message + from job_request + where id = ANY ($1) + and status = 100 + `, + [jobRequestIds] + ); + + // wait for all campaign exports to process + const exportsStillProcessing = rows.length !== campaignIds.length; + + // map query result to campaign id and parse JSON + const campaignExportsMap = rows.map((result) => { + const { campaign_id, result_message } = result; + // exportUrls are nested JSON + const messageObj = JSON.parse(result_message); + const exportUrls = JSON.parse(messageObj.exportUrls); + return { + campaignId: campaign_id, + exportUrls + }; + }); + + // map fetched export urls to campaignMetaData + for (const campaignExport of campaignExportsMap) { + if ( + !Object.keys(campaignMetaDataMap).includes( + campaignExport.campaignId.toString() + ) + ) { + throw new Error("attempted to store exportUrls for incorrect campaign"); + } + campaignMetaDataMap[campaignExport.campaignId].exportUrls = + campaignExport.exportUrls; + } + + try { + if (exportsStillProcessing) { + // re-queue a new job to poll for export urls + await helpers.addJob(helpers.job.task_identifier, helpers.job.payload, { + runAt: DateTime.now().plus({ minutes: 2 }).toJSDate() + }); + helpers.logger.info( + `Exports still processing - queuing new job at ${DateTime.now().plus({ + minutes: 2 + })}` + ); + // early return to fail successfully + return; + } + const campaignIdsString = campaignIds.join(", "); + // get email + const { notificationEmail } = await fetchExportData( + campaignId, + requesterId + ); + // trigger retry by throwing if export urls is null for a campaign + const exportContent = formatMultipleCampaignExportsEmail( + campaignMetaDataMap + ); + await sendEmail({ + to: notificationEmail, + subject: `Export(s) ready for campaign(s) ${campaignIdsString}`, + html: exportContent + }); + helpers.logger.info(`Successfully sent email for bulk export operation`); + // remove export_campaign job_requests from requests table + for (const id of jobRequestIds) { + await helpers.cleanUpJobRequest(id); + } + } finally { + helpers.logger.info("Successfully completed bulk export operation"); + } +}; + +// add jobs for each campaign export to the job_requests table +export const addExportMultipleCampaigns = async ( + payload: ExportMultipleCampaignsPayload +) => { + const { campaignIds, ...rest } = payload; + const jobRequestRecords: JobRequestRecord[] = []; + // dispatch individual progress job for each campaign export + for (const campaignId of campaignIds) { + const innerPayload = { ...rest, campaignId }; + const requestRecord = await addProgressJob({ + identifier: EXPORT_TASK_IDENTIFIER, + payload: innerPayload, + taskSpec: { + queueName: "export-campaigns-for-bulk-operation" + } + }); + + jobRequestRecords.push(requestRecord); + } + + // satisfy ProgressJobPayload[campaignId]: required + const emailTaskPayload = { + ...payload, + campaignId: campaignIds[0], + jobRequestRecords + }; + // dispatch a single job to email exportUrls, allow 2 minutes for exports to process + await addProgressJob({ + identifier: EMAIL_TASK_IDENTIFIER, + payload: emailTaskPayload, + taskSpec: { + queueName: "send-email-after-bulk-export", + runAt: DateTime.now().plus({ minutes: 4 }).toJSDate() + } + }); + return jobRequestRecords; +}; diff --git a/src/server/tasks/utils.ts b/src/server/tasks/utils.ts index 30d785ac3..7513f64a4 100644 --- a/src/server/tasks/utils.ts +++ b/src/server/tasks/utils.ts @@ -100,6 +100,7 @@ export interface ProgressTaskHelpers extends JobHelpers { jobRequest: JobRequestRecord; updateStatus(status: number): Promise; updateResult(result: Record): Promise; + cleanUpJobRequest: (id: number) => Promise; } export type ProgressTask

= ( @@ -130,11 +131,16 @@ export const wrapProgressTask =

( .where({ id: jobId }); }; + const cleanUpJobRequest = async (id: number) => { + await r.knex("job_request").where({ id }).del(); + }; + const progressHelpers: ProgressTaskHelpers = { ...helpers, jobRequest, updateStatus, - updateResult + updateResult, + cleanUpJobRequest }; try { diff --git a/src/server/worker.ts b/src/server/worker.ts index e5f6d4b4d..acc01af04 100644 --- a/src/server/worker.ts +++ b/src/server/worker.ts @@ -23,6 +23,12 @@ import { exportForVan, TASK_IDENTIFIER as exportForVanIdentifier } from "./tasks/export-for-van"; +import { + EMAIL_TASK_IDENTIFIER as sendEmailForBulkOperationIdentifier, + EXPORT_TASK_IDENTIFIER as exportCampaignForBulkOperationIdentifier, + exportCampaignForBulkOperation, + sendEmailForBulkExportOperation +} from "./tasks/export-multiple-campaigns"; import { exportOptOuts, TASK_IDENTIFIER as exportOptOutsIdentifier @@ -107,6 +113,18 @@ export const getWorker = async (attempt = 0): Promise => { [exportForVanIdentifier]: wrapProgressTask(exportForVan, { removeOnComplete: true }), + [exportCampaignForBulkOperationIdentifier]: wrapProgressTask( + exportCampaignForBulkOperation, + { + removeOnComplete: false + } + ), + [sendEmailForBulkOperationIdentifier]: wrapProgressTask( + sendEmailForBulkExportOperation, + { + removeOnComplete: true + } + ), [filterLandlinesIdentifier]: wrapProgressTask(filterLandlines, { removeOnComplete: false }),