From 14540d99f3c37436454a84082d55d2f29c9a4f03 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Wed, 3 Jun 2026 10:19:53 -0700 Subject: [PATCH 01/10] feat(campaigns): add campaign type (SMS/Call) to campaign editor --- libs/gql-schema/campaign.ts | 8 + .../20260601000001_add-campaign-type.js | 19 ++ .../20260601000002_create-call-table.js | 38 ++++ .../components/SectionWrapper.tsx | 1 + src/containers/AdminCampaignEdit/index.jsx | 23 ++- src/containers/AdminCampaignEdit/queries.ts | 2 + .../sections/CampaignTextingHoursForm.tsx | 6 +- .../sections/CampaignTypeForm.tsx | 186 ++++++++++++++++++ src/containers/AdminCampaignEdit/types.ts | 1 + src/schema.graphql | 8 + src/server/api/campaign.js | 2 + src/server/api/lib/campaign.ts | 8 +- src/server/api/types.ts | 1 + 13 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 migrations/20260601000001_add-campaign-type.js create mode 100644 migrations/20260601000002_create-call-table.js create mode 100644 src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx diff --git a/libs/gql-schema/campaign.ts b/libs/gql-schema/campaign.ts index c261cd75a..2d0c8a39a 100644 --- a/libs/gql-schema/campaign.ts +++ b/libs/gql-schema/campaign.ts @@ -1,4 +1,9 @@ export const schema = ` + enum CampaignType { + SMS + CALL + } + input CampaignsFilter { isArchived: Boolean isStarted: Boolean @@ -52,6 +57,7 @@ export const schema = ` type CampaignReadiness { id: ID! basics: Boolean! + campaignType: Boolean! messagingService: Boolean! textingHours: Boolean! integration: Boolean! @@ -128,6 +134,7 @@ export const schema = ` columnMapping: [CsvColumnMapping!] messagingService: MessagingService contactsFilename: String + campaignType: CampaignType! } type CampaignEdge { @@ -201,6 +208,7 @@ export const schema = ` messagingServiceSid: String autosendLimit: Int columnMapping: [CsvColumnMappingInput!] + campaignType: CampaignType } `; export default schema; diff --git a/migrations/20260601000001_add-campaign-type.js b/migrations/20260601000001_add-campaign-type.js new file mode 100644 index 000000000..1aaf3d59b --- /dev/null +++ b/migrations/20260601000001_add-campaign-type.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.alterTable("all_campaign", (table) => { + table.enu("type", ["sms", "call"]).notNullable().defaultTo("sms"); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.alterTable("all_campaign", (table) => { + table.dropColumn("type"); + }); +}; diff --git a/migrations/20260601000002_create-call-table.js b/migrations/20260601000002_create-call-table.js new file mode 100644 index 000000000..38f62088a --- /dev/null +++ b/migrations/20260601000002_create-call-table.js @@ -0,0 +1,38 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.createTable("call", (table) => { + table.increments("id").primary(); + table + .integer("campaign_contact_id") + .notNullable() + .references("id") + .inTable("campaign_contact"); + table.integer("user_id").notNullable().references("id").inTable("user"); + table.text("telnyx_call_control_id").nullable(); + table + .enu("status", [ + "QUEUED", + "DIALING", + "IN_PROGRESS", + "COMPLETED", + "NO_ANSWER", + "VOICEMAIL", + "ERROR" + ]) + .notNullable() + .defaultTo("QUEUED"); + table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); + table.timestamp("ended_at").nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.dropTable("call"); +}; diff --git a/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx b/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx index 7fc8abb04..e639ff01f 100644 --- a/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx +++ b/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx @@ -301,6 +301,7 @@ const makeQueries = (jobTypes: string[]) => ({ readiness { id basics + campaignType messagingService textingHours integration diff --git a/src/containers/AdminCampaignEdit/index.jsx b/src/containers/AdminCampaignEdit/index.jsx index 45490579b..5ec175499 100644 --- a/src/containers/AdminCampaignEdit/index.jsx +++ b/src/containers/AdminCampaignEdit/index.jsx @@ -55,6 +55,7 @@ import CampaignOverlapManager from "./sections/CampaignOverlapManager"; import CampaignTeamsForm from "./sections/CampaignTeamsForm"; import CampaignTextersForm from "./sections/CampaignTextersForm"; import CampaignTextingHoursForm from "./sections/CampaignTextingHoursForm"; +import CampaignTypeForm from "./sections/CampaignTypeForm"; import CampaignVariablesForm from "./sections/CampaignVariablesForm"; class AdminCampaignEdit extends React.Component { @@ -299,7 +300,21 @@ class AdminCampaignEdit extends React.Component { }; sections = () => { + const isCallCampaign = + this.state.campaignFormValues.campaignType === "CALL"; + const sections = [ + { + title: "Campaign Type", + content: CampaignTypeForm, + isStandalone: true, + showForModes: [CampaignBuilderMode.Basic, CampaignBuilderMode.Advanced], + keys: ["campaignType"], + checkCompleted: () => true, + blocksStarting: false, + expandAfterCampaignStarts: true, + expandableBySuperVolunteers: false + }, { title: "Basics", content: CampaignBasicsForm, @@ -351,11 +366,12 @@ class AdminCampaignEdit extends React.Component { expandAfterCampaignStarts: false, expandableBySuperVolunteers: false, exclude: + isCallCampaign || this.props.organizationData?.organization?.messagingServices?.edges ?.length <= 1 }, { - title: "Texting Hours", + title: "Contact Hours", content: CampaignTextingHoursForm, isStandalone: true, showForModes: [CampaignBuilderMode.Advanced], @@ -448,8 +464,9 @@ class AdminCampaignEdit extends React.Component { expandAfterCampaignStarts: false, extraProps: {}, exclude: - this.props.organizationData.organization && - !this.props.organizationData.organization.numbersApiKey + isCallCampaign || + (this.props.organizationData.organization && + !this.props.organizationData.organization.numbersApiKey) }, { title: "Contact Overlap Management", diff --git a/src/containers/AdminCampaignEdit/queries.ts b/src/containers/AdminCampaignEdit/queries.ts index 62a435ee2..bbd10176c 100644 --- a/src/containers/AdminCampaignEdit/queries.ts +++ b/src/containers/AdminCampaignEdit/queries.ts @@ -101,6 +101,7 @@ export const EditCampaignFragment = gql` } readiness { basics + campaignType textingHours integration contacts @@ -111,6 +112,7 @@ export const EditCampaignFragment = gql` texters } contactsFilename + campaignType } `; diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextingHoursForm.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextingHoursForm.tsx index a50bc9610..91da4f1fe 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextingHoursForm.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextingHoursForm.tsx @@ -190,8 +190,8 @@ class CampaignTextingHoursForm extends React.Component<
{this.addAutocompleteFormField( @@ -283,7 +283,7 @@ const mutations = { export default compose( asSection({ - title: "Texting Hours", + title: "Contact Hours", readinessName: "textingHours", jobQueueNames: [], expandAfterCampaignStarts: true, diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx new file mode 100644 index 000000000..78e4b46e8 --- /dev/null +++ b/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx @@ -0,0 +1,186 @@ +import type { ApolloQueryResult } from "@apollo/client"; +import { gql } from "@apollo/client"; +import Button from "@material-ui/core/Button"; +import FormControl from "@material-ui/core/FormControl"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import FormLabel from "@material-ui/core/FormLabel"; +import Radio from "@material-ui/core/Radio"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import React from "react"; +import { compose } from "recompose"; + +import { loadData } from "../../hoc/with-operations"; +import CampaignFormSectionHeading from "../components/CampaignFormSectionHeading"; +import type { + FullComponentProps, + RequiredComponentProps +} from "../components/SectionWrapper"; +import { asSection } from "../components/SectionWrapper"; + +type CampaignTypeValue = "SMS" | "CALL"; + +interface CampaignTypeFormValues { + campaignType: CampaignTypeValue; +} + +interface CampaignTypeHocProps { + data: { + campaign: CampaignTypeFormValues & { id: string; isStarted: boolean }; + }; + mutations: { + editCampaign(payload: CampaignTypeFormValues): ApolloQueryResult; + }; +} + +interface CampaignTypeInnerProps + extends FullComponentProps, + CampaignTypeHocProps {} + +interface CampaignTypeState { + campaignType?: CampaignTypeValue; + isWorking: boolean; +} + +class CampaignTypeForm extends React.Component< + CampaignTypeInnerProps, + CampaignTypeState +> { + state: CampaignTypeState = { + campaignType: undefined, + isWorking: false + }; + + handleChange = ( + _event: React.ChangeEvent, + value: string + ) => { + this.setState({ campaignType: value as CampaignTypeValue }); + }; + + handleSubmit = async () => { + const { campaignType } = this.state; + const { editCampaign } = this.props.mutations; + + this.setState({ isWorking: true }); + try { + const response = await editCampaign({ campaignType: campaignType! }); + if (response.errors) throw response.errors; + this.setState({ campaignType: undefined }); + } catch (err) { + this.props.onError(err.message); + } finally { + this.setState({ isWorking: false }); + } + }; + + render() { + const { isWorking } = this.state; + const { + data: { campaign }, + saveLabel + } = this.props; + + const campaignType = + this.state.campaignType !== undefined + ? this.state.campaignType + : campaign.campaignType; + + const hasPendingChanges = + this.state.campaignType !== undefined && + this.state.campaignType !== campaign.campaignType; + + const isSaveDisabled = + isWorking || !hasPendingChanges || campaign.isStarted; + const finalSaveLabel = isWorking ? "Working..." : saveLabel; + + return ( +
+ + + Type + + } label="SMS" /> + } label="Call" /> + + + {campaign.isStarted && ( +

+ Campaign type cannot be changed after the campaign has started. +

+ )} +
+ +
+
+ ); + } +} + +const queries = { + data: { + query: gql` + query getCampaignType($campaignId: String!) { + campaign(id: $campaignId) { + id + isStarted + campaignType + } + } + `, + options: (ownProps: CampaignTypeInnerProps) => ({ + variables: { + campaignId: ownProps.campaignId + } + }) + } +}; + +const mutations = { + editCampaign: (ownProps: CampaignTypeInnerProps) => ( + payload: CampaignTypeFormValues + ) => ({ + mutation: gql` + mutation editCampaignType( + $campaignId: String! + $payload: CampaignInput! + ) { + editCampaign(id: $campaignId, campaign: $payload) { + id + campaignType + } + } + `, + variables: { + campaignId: ownProps.campaignId, + payload + } + }) +}; + +export default compose( + asSection({ + title: "Campaign Type", + readinessName: "campaignType", + jobQueueNames: [], + expandAfterCampaignStarts: true, + expandableBySuperVolunteers: false + }), + loadData({ + queries, + mutations + }) +)(CampaignTypeForm); diff --git a/src/containers/AdminCampaignEdit/types.ts b/src/containers/AdminCampaignEdit/types.ts index 8ca9e6868..be5903894 100644 --- a/src/containers/AdminCampaignEdit/types.ts +++ b/src/containers/AdminCampaignEdit/types.ts @@ -1,5 +1,6 @@ export interface CampaignReadinessType { basics: boolean; + campaignType: boolean; messagingService: boolean; textingHours: boolean; integration: boolean; diff --git a/src/schema.graphql b/src/schema.graphql index 645238f89..e14a7aea7 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -566,6 +566,11 @@ type OrganizationMembershipPage { +enum CampaignType { + SMS + CALL +} + input CampaignsFilter { isArchived: Boolean isStarted: Boolean @@ -619,6 +624,7 @@ type JobRequest { type CampaignReadiness { id: ID! basics: Boolean! + campaignType: Boolean! messagingService: Boolean! textingHours: Boolean! integration: Boolean! @@ -695,6 +701,7 @@ type Campaign { columnMapping: [CsvColumnMapping!] messagingService: MessagingService contactsFilename: String + campaignType: CampaignType! } type CampaignEdge { @@ -768,6 +775,7 @@ input CampaignInput { messagingServiceSid: String autosendLimit: Int columnMapping: [CsvColumnMappingInput!] + campaignType: CampaignType } diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 861c7d05b..0e7a0b38f 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -435,6 +435,7 @@ export const resolvers = { return hasSteps && !hasIncompleteSteps && invalidFields.length === 0; }, + campaignType: () => true, campaignGroups: () => true, campaignVariables: (campaign) => r @@ -491,6 +492,7 @@ export const resolvers = { "autosendLimit", "columnMapping" ]), + campaignType: (campaign) => (campaign.type ?? "sms").toUpperCase(), isApproved: (campaign) => isNil(campaign.is_approved) ? false : campaign.is_approved, isTemplate: (campaign) => diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 6dbf02fdb..344af9356 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -522,7 +522,8 @@ export const editCampaign = async ( timezone, externalSystemId, messagingServiceSid, - columnMapping + columnMapping, + campaignType } = campaign; const organizationId = origCampaignRecord.organization_id; @@ -541,7 +542,10 @@ export const editCampaign = async ( replies_stale_after_minutes: repliesStaleAfter, // this is null to unset it - it must be null, not undefined timezone: timezone ? parseIanaZone(timezone) : undefined, external_system_id: externalSystemId, - messaging_service_sid: messagingServiceSid ?? undefined + messaging_service_sid: messagingServiceSid ?? undefined, + type: campaignType + ? (campaignType.toLowerCase() as "sms" | "call") + : undefined }; Object.keys(campaignUpdates).forEach((key) => { diff --git a/src/server/api/types.ts b/src/server/api/types.ts index 04ef18bc2..bfc8097d5 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -126,6 +126,7 @@ export interface CampaignRecord { messaging_service_sid: string | null; column_mapping: string | null; contacts_filename: string | null; + type: "sms" | "call"; } export interface CampaignVariableRecord { From 26fe7ee4e5a9a6615bf90c14e51df6173df84377 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Thu, 4 Jun 2026 07:05:02 -0700 Subject: [PATCH 02/10] feat(admin): add campaign type to admin UI --- .gitignore | 1 + src/containers/AdminCampaignEdit/index.jsx | 4 ++-- .../sections/CampaignCannedResponsesForm/index.tsx | 4 ++-- .../CampaignTextersForm/components/AddRemoveTexters.tsx | 4 ++-- .../components/TexterAssignmentHeaderRow.tsx | 2 +- .../components/TexterAssignmentSummary.tsx | 2 +- .../sections/CampaignTextersForm/index.tsx | 6 +++--- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 854949355..4c8b4178b 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,5 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +.claude/* # End of https://www.gitignore.io/api/node,linux,macos,windows diff --git a/src/containers/AdminCampaignEdit/index.jsx b/src/containers/AdminCampaignEdit/index.jsx index 5ec175499..ec4ce9d66 100644 --- a/src/containers/AdminCampaignEdit/index.jsx +++ b/src/containers/AdminCampaignEdit/index.jsx @@ -512,7 +512,7 @@ class AdminCampaignEdit extends React.Component { } }, { - title: "Texters", + title: "Volunteers", content: CampaignTextersForm, isStandalone: true, showForModes: [CampaignBuilderMode.Advanced], @@ -614,7 +614,7 @@ class AdminCampaignEdit extends React.Component { (job) => job.jobType === "upload_contacts" || job.jobType === "contact_sql" ); - } else if (section.title === "Texters") { + } else if (section.title === "Volunteers") { [relatedJob] = pendingJobs.filter( (job) => job.jobType === "assign_texters" ); diff --git a/src/containers/AdminCampaignEdit/sections/CampaignCannedResponsesForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignCannedResponsesForm/index.tsx index 2e637bd3c..c5785d7f0 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignCannedResponsesForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignCannedResponsesForm/index.tsx @@ -395,10 +395,10 @@ class CampaignCannedResponsesForm extends React.Component { return (
- Share additional FAQ responses with your texters that are NOT + Share additional FAQ responses with your volunteers that are NOT associated with logging data. Please note that canned responses are not tracked or stored when used. Responses associated with data collection should be included in your{" "} diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/AddRemoveTexters.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/AddRemoveTexters.tsx index 1880f57b2..bf6669771 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/AddRemoveTexters.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/AddRemoveTexters.tsx @@ -48,8 +48,8 @@ export const AddRemoveTexters: React.FC = (props) => { renderInput={(params) => ( )} diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentHeaderRow.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentHeaderRow.tsx index e19204f69..2f31e4de9 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentHeaderRow.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentHeaderRow.tsx @@ -18,7 +18,7 @@ const TexterAssignmentHeaderRow: React.FC = () => (
- Already texted + Already contacted
diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentSummary.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentSummary.tsx index abd668929..58aac60a1 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentSummary.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/components/TexterAssignmentSummary.tsx @@ -81,7 +81,7 @@ const TexterAssignmentSummary: React.FC = (props) => { name="autoSplit" /> } - label="Split remaining unsent messages" + label="Split remaining unassigned contacts" />
diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx index 1e51fa085..2ce7a6838 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx @@ -210,12 +210,12 @@ const CampaignTextersForm: React.FC = (props) => { return ( <> This campaign is overdue! Please change the due date before - editing Texters + editing Volunteers ) } @@ -313,7 +313,7 @@ const mutations: MutationMap = { export default compose( asSection({ - title: "Texters", + title: "Volunteers", readinessName: "texters", jobQueueNames: JOB_QUEUE_NAMES, expandAfterCampaignStarts: true, From e0b4c033fd888516522802604133068c160727a4 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Sat, 6 Jun 2026 07:10:28 -0700 Subject: [PATCH 03/10] feat(admin): add new dialer tables --- .../20260601000001_add-campaign-type.js | 37 +++++++++-- ...01000002_create-dialer-campaign-contact.js | 63 +++++++++++++++++++ ...s => 20260601000003_create-dialer-call.js} | 16 +++-- ...1000004_create-dialer-question-response.js | 45 +++++++++++++ ...0005_create-dialer-campaign-contact-tag.js | 36 +++++++++++ 5 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 migrations/20260601000002_create-dialer-campaign-contact.js rename migrations/{20260601000002_create-call-table.js => 20260601000003_create-dialer-call.js} (59%) create mode 100644 migrations/20260601000004_create-dialer-question-response.js create mode 100644 migrations/20260601000005_create-dialer-campaign-contact-tag.js diff --git a/migrations/20260601000001_add-campaign-type.js b/migrations/20260601000001_add-campaign-type.js index 1aaf3d59b..cd4d3e912 100644 --- a/migrations/20260601000001_add-campaign-type.js +++ b/migrations/20260601000001_add-campaign-type.js @@ -2,18 +2,47 @@ * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.up = function up(knex) { - return knex.schema.alterTable("all_campaign", (table) => { +exports.up = async function up(knex) { + await knex.schema.alterTable("all_campaign", (table) => { table.enu("type", ["sms", "call"]).notNullable().defaultTo("sms"); }); + + // Safety guards: a call campaign must never run texting-only background work. + // Autosending, autoassignment, and stale-reply release all act through + // campaign_contact (which call campaigns don't have), but we ALSO pin the + // controlling columns to their inert values at the DB level so the invalid + // states are impossible. As a bonus, each cron's candidate query filters on + // exactly these columns, so call campaigns are self-excluded. These simple + // same-row CHECKs are only possible because `type` lives on this table. + await knex.raw(` + alter table all_campaign + add constraint call_campaigns_no_autosend + check (type <> 'call' or autosend_status = 'unstarted'), + add constraint call_campaigns_no_autoassign + check (type <> 'call' or is_autoassign_enabled = false), + add constraint call_campaigns_no_stale_release + check (type <> 'call' or replies_stale_after_minutes is null); + `); + + // NOTE: the `campaign` view is intentionally NOT recreated to expose `type`. + // Every read of campaignType goes through the single-campaign loader, which + // reads all_campaign directly. If a campaigns-list/conversations query ever + // needs campaignType, expose `type` on the `campaign` view (or read + // all_campaign in that query) at that point. }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.down = function down(knex) { - return knex.schema.alterTable("all_campaign", (table) => { +exports.down = async function down(knex) { + await knex.raw(` + alter table all_campaign + drop constraint if exists call_campaigns_no_autosend, + drop constraint if exists call_campaigns_no_autoassign, + drop constraint if exists call_campaigns_no_stale_release; + `); + await knex.schema.alterTable("all_campaign", (table) => { table.dropColumn("type"); }); }; diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js new file mode 100644 index 000000000..54499b71c --- /dev/null +++ b/migrations/20260601000002_create-dialer-campaign-contact.js @@ -0,0 +1,63 @@ +/** + * Standalone contacts table for dialer (Call) campaigns. Deliberately separate + * from campaign_contact so dialer contacts never carry texting-only columns + * (message_status, is_opted_out, autosend). Reuses the shared `assignment` table + * via assignment_id so the existing assignment plumbing still applies. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.createTable("dialer_campaign_contact", (table) => { + table.increments("id").primary(); + table + .integer("campaign_id") + .notNullable() + .references("id") + .inTable("all_campaign") + .onDelete("CASCADE"); + table + .integer("assignment_id") + .nullable() + .references("id") + .inTable("assignment") + .onDelete("SET NULL"); + // Contact identity (owned here, not via campaign_contact) + table.text("external_id"); + table.text("first_name").notNullable(); + table.text("last_name").notNullable(); + table.text("cell").notNullable(); + table.text("zip"); + table.text("timezone"); + table.jsonb("custom_fields").notNullable().defaultTo("{}"); + // Dialer state + table + .enu("call_status", [ + "not_attempted", + "queued", + "in_progress", + "completed", + "no_answer", + "voicemail", + "error" + ]) + .notNullable() + .defaultTo("not_attempted"); + table.boolean("do_not_call").notNullable().defaultTo(false); + table.integer("attempt_count").notNullable().defaultTo(0); + table.timestamp("last_attempted_at").nullable(); + table.boolean("archived").notNullable().defaultTo(false); + table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); + table.timestamp("updated_at").defaultTo(knex.fn.now()).notNullable(); + table.index("campaign_id"); + table.index("assignment_id"); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.dropTable("dialer_campaign_contact"); +}; diff --git a/migrations/20260601000002_create-call-table.js b/migrations/20260601000003_create-dialer-call.js similarity index 59% rename from migrations/20260601000002_create-call-table.js rename to migrations/20260601000003_create-dialer-call.js index 38f62088a..f74befc9a 100644 --- a/migrations/20260601000002_create-call-table.js +++ b/migrations/20260601000003_create-dialer-call.js @@ -1,17 +1,23 @@ /** + * One row per call attempt against a dialer contact. The dialer analogue of the + * `message` table. from_number records the caller ID used; + * disposition is the volunteer-recorded outcome. + * * @param { import("knex").Knex } knex * @returns { Promise } */ exports.up = function up(knex) { - return knex.schema.createTable("call", (table) => { + return knex.schema.createTable("dialer_call", (table) => { table.increments("id").primary(); table - .integer("campaign_contact_id") + .integer("dialer_campaign_contact_id") .notNullable() .references("id") - .inTable("campaign_contact"); + .inTable("dialer_campaign_contact") + .onDelete("CASCADE"); table.integer("user_id").notNullable().references("id").inTable("user"); table.text("telnyx_call_control_id").nullable(); + table.text("from_number").nullable(); table .enu("status", [ "QUEUED", @@ -24,8 +30,10 @@ exports.up = function up(knex) { ]) .notNullable() .defaultTo("QUEUED"); + table.text("disposition").nullable(); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); table.timestamp("ended_at").nullable(); + table.index("dialer_campaign_contact_id"); }); }; @@ -34,5 +42,5 @@ exports.up = function up(knex) { * @returns { Promise } */ exports.down = function down(knex) { - return knex.schema.dropTable("call"); + return knex.schema.dropTable("dialer_call"); }; diff --git a/migrations/20260601000004_create-dialer-question-response.js b/migrations/20260601000004_create-dialer-question-response.js new file mode 100644 index 000000000..b2f57ec4f --- /dev/null +++ b/migrations/20260601000004_create-dialer-question-response.js @@ -0,0 +1,45 @@ +/** + * Survey answers captured during a call. The dialer analogue of + * question_response: it FKs the dialer contact, but reuses the SHARED + * interaction_step (the script tree is campaign-level and channel-agnostic). + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema.createTable("dialer_question_response", (table) => { + table.increments("id").primary(); + table + .integer("dialer_campaign_contact_id") + .notNullable() + .references("id") + .inTable("dialer_campaign_contact") + .onDelete("CASCADE"); + table + .integer("interaction_step_id") + .notNullable() + .references("id") + .inTable("interaction_step") + .onDelete("CASCADE"); + table.text("value").notNullable(); + table.boolean("is_deleted").notNullable().defaultTo(false); + table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); + table.timestamp("updated_at").defaultTo(knex.fn.now()).notNullable(); + table.index("dialer_campaign_contact_id"); + }); + + // Mirror question_response: at most one live answer per (step, contact). + await knex.raw(` + create unique index dialer_qr_step_contact_idx + on dialer_question_response (interaction_step_id, dialer_campaign_contact_id) + where is_deleted = false; + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.dropTable("dialer_question_response"); +}; diff --git a/migrations/20260601000005_create-dialer-campaign-contact-tag.js b/migrations/20260601000005_create-dialer-campaign-contact-tag.js new file mode 100644 index 000000000..99533dadc --- /dev/null +++ b/migrations/20260601000005_create-dialer-campaign-contact-tag.js @@ -0,0 +1,36 @@ +/** + * Contact tags applied during a call. Mirrors campaign_contact_tag (tagger_id, + * composite PK), but FKs the dialer contact. Reuses the SHARED tag vocabulary + * (all_tag) and `user` so tags mean the same thing across texting and calling. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.createTable("dialer_campaign_contact_tag", (table) => { + table + .integer("dialer_campaign_contact_id") + .notNullable() + .references("id") + .inTable("dialer_campaign_contact") + .onDelete("CASCADE"); + table + .integer("tag_id") + .notNullable() + .references("id") + .inTable("all_tag") + .onDelete("CASCADE"); + table.integer("tagger_id").notNullable().references("id").inTable("user"); + table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + table.primary(["dialer_campaign_contact_id", "tag_id"]); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.dropTable("dialer_campaign_contact_tag"); +}; From 31539f4ef902dc38db08cacc7d875a99beb1621d Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Sat, 6 Jun 2026 07:51:54 -0700 Subject: [PATCH 04/10] chore(admin): address feedback --- .../src/graphql/campaign-builder.graphql | 16 ++ .../components/SectionWrapper.tsx | 43 ++- src/containers/AdminCampaignEdit/index.jsx | 6 +- .../sections/CampaignTypeForm.tsx | 248 +++++++----------- src/server/api/campaign.js | 2 +- src/server/api/lib/campaign.ts | 5 +- src/server/api/types.ts | 3 +- 7 files changed, 158 insertions(+), 165 deletions(-) diff --git a/libs/spoke-codegen/src/graphql/campaign-builder.graphql b/libs/spoke-codegen/src/graphql/campaign-builder.graphql index 6f2db768a..4e6646ae4 100644 --- a/libs/spoke-codegen/src/graphql/campaign-builder.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-builder.graphql @@ -77,3 +77,19 @@ mutation StartCampaign($campaignId: String!) { isApproved } } + +query GetCampaignType($campaignId: String!) { + campaign(id: $campaignId) { + id + isStarted + campaignType + contactsCount + } +} + +mutation EditCampaignType($campaignId: String!, $payload: CampaignInput!) { + editCampaign(id: $campaignId, campaign: $payload) { + id + campaignType + } +} diff --git a/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx b/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx index e639ff01f..afab25ef3 100644 --- a/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx +++ b/src/containers/AdminCampaignEdit/components/SectionWrapper.tsx @@ -14,6 +14,7 @@ import CancelIcon from "@material-ui/icons/Cancel"; import DoneIcon from "@material-ui/icons/Done"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; import WarningIcon from "@material-ui/icons/Warning"; +import type { CampaignType } from "@spoke/spoke-codegen"; import clsx from "clsx"; import isNil from "lodash/isNil"; import React from "react"; @@ -56,6 +57,10 @@ interface WrapperProps { isExpandable: boolean; sectionIsDone: boolean; deleteJob: DeleteJobType; + + // Optional: render a custom status avatar (e.g. a campaign-type icon) instead + // of the default done/warning indicator. + avatarOverride?: React.ReactNode; } const useStyles = makeStyles((theme) => ({ @@ -109,6 +114,10 @@ const useStyles = makeStyles((theme) => ({ warningIcon: { color: theme.palette.warning.main }, + typeIcon: { + display: "flex", + color: theme.palette.success.main + }, expand: { transform: "rotate(0deg)", marginLeft: "auto", @@ -161,7 +170,8 @@ export const SectionWrapper: React.FC = (props) => { isExpandable, pendingJob, sectionIsDone, - deleteJob + deleteJob, + avatarOverride } = props; const classes = useStyles(); @@ -188,6 +198,17 @@ export const SectionWrapper: React.FC = (props) => { ); classNames.push(classes.saving); cardHeaderStyle.width = `${progressPercent}%`; + } else if (avatarOverride) { + avatar = ( + + {avatarOverride} + + ); + if (active && expandable) { + classNames.push(classes.active); + } else if (!expandable) { + classNames.push(classes.unexpandable); + } } else if (active && expandable) { classNames.push(classes.active); } else if (!expandable) { @@ -298,6 +319,7 @@ const makeQueries = (jobTypes: string[]) => ({ id isStarted isApproved + campaignType readiness { id basics @@ -373,6 +395,9 @@ export interface SectionOptions { jobQueueNames: string[]; expandAfterCampaignStarts: boolean; expandableBySuperVolunteers: boolean; + // Optional: render a custom status avatar based on the campaign instead of the + // default done/warning indicator (e.g. a text/call icon for Campaign Type). + avatarIcon?: (campaign: { campaignType: CampaignType }) => React.ReactNode; } interface WrapperGraphqlProps { @@ -380,6 +405,7 @@ interface WrapperGraphqlProps { campaign: { id: string; isStarted: boolean; + campaignType: CampaignType; readiness: CampaignReadinessType; }; }; @@ -402,6 +428,7 @@ interface WrappedComponentProps isExpandable: boolean; sectionIsDone: boolean; deleteJob: DeleteJobType; + avatarOverride?: React.ReactNode; } export const asSection = (options: SectionOptions) => ( @@ -434,7 +461,17 @@ export const asSection = (options: SectionOptions) => ( const sectionIsDone = readiness[readinessName]; - return { pendingJob, isExpandable, sectionIsDone, deleteJob }; + const avatarOverride = options.avatarIcon + ? options.avatarIcon(status.campaign) + : undefined; + + return { + pendingJob, + isExpandable, + sectionIsDone, + deleteJob, + avatarOverride + }; }) )((props) => { const { @@ -455,6 +492,7 @@ export const asSection = (options: SectionOptions) => ( isExpandable, sectionIsDone, deleteJob, + avatarOverride, ...otherProps } = props; @@ -471,6 +509,7 @@ export const asSection = (options: SectionOptions) => ( isExpandable={isExpandable} sectionIsDone={sectionIsDone} deleteJob={deleteJob} + avatarOverride={avatarOverride} > { const isCallCampaign = - this.state.campaignFormValues.campaignType === "CALL"; + this.state.campaignFormValues.campaignType === CampaignType.Call; const sections = [ { @@ -312,7 +312,7 @@ class AdminCampaignEdit extends React.Component { keys: ["campaignType"], checkCompleted: () => true, blocksStarting: false, - expandAfterCampaignStarts: true, + expandAfterCampaignStarts: false, expandableBySuperVolunteers: false }, { diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx index 78e4b46e8..fc6d8fa16 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTypeForm.tsx @@ -1,186 +1,122 @@ -import type { ApolloQueryResult } from "@apollo/client"; -import { gql } from "@apollo/client"; import Button from "@material-ui/core/Button"; import FormControl from "@material-ui/core/FormControl"; import FormControlLabel from "@material-ui/core/FormControlLabel"; import FormLabel from "@material-ui/core/FormLabel"; import Radio from "@material-ui/core/Radio"; import RadioGroup from "@material-ui/core/RadioGroup"; -import React from "react"; +import PhoneIcon from "@material-ui/icons/Phone"; +import SmsIcon from "@material-ui/icons/Sms"; +import { + CampaignType, + useEditCampaignTypeMutation, + useGetCampaignTypeQuery +} from "@spoke/spoke-codegen"; +import React, { useState } from "react"; import { compose } from "recompose"; -import { loadData } from "../../hoc/with-operations"; import CampaignFormSectionHeading from "../components/CampaignFormSectionHeading"; -import type { - FullComponentProps, - RequiredComponentProps -} from "../components/SectionWrapper"; +import type { FullComponentProps } from "../components/SectionWrapper"; import { asSection } from "../components/SectionWrapper"; -type CampaignTypeValue = "SMS" | "CALL"; - -interface CampaignTypeFormValues { - campaignType: CampaignTypeValue; -} - -interface CampaignTypeHocProps { - data: { - campaign: CampaignTypeFormValues & { id: string; isStarted: boolean }; - }; - mutations: { - editCampaign(payload: CampaignTypeFormValues): ApolloQueryResult; - }; -} - -interface CampaignTypeInnerProps - extends FullComponentProps, - CampaignTypeHocProps {} - -interface CampaignTypeState { - campaignType?: CampaignTypeValue; - isWorking: boolean; -} - -class CampaignTypeForm extends React.Component< - CampaignTypeInnerProps, - CampaignTypeState -> { - state: CampaignTypeState = { - campaignType: undefined, - isWorking: false - }; - - handleChange = ( +const CampaignTypeForm: React.FC = (props) => { + const { campaignId, saveLabel, onError } = props; + const [pendingType, setPendingType] = useState( + undefined + ); + const [isWorking, setIsWorking] = useState(false); + + const { data } = useGetCampaignTypeQuery({ variables: { campaignId } }); + const [editCampaignType] = useEditCampaignTypeMutation(); + + const campaign = data?.campaign; + const isStarted = campaign?.isStarted ?? false; + const hasContacts = (campaign?.contactsCount ?? 0) > 0; + // Contacts live in type-specific tables, so the type can't change once they're + // uploaded (or the campaign has started). + const isLocked = isStarted || hasContacts; + const savedType = campaign?.campaignType ?? CampaignType.Sms; + const campaignType = pendingType ?? savedType; + + const hasPendingChanges = + pendingType !== undefined && pendingType !== savedType; + const isSaveDisabled = isWorking || !hasPendingChanges || isLocked; + const finalSaveLabel = isWorking ? "Working..." : saveLabel; + + const handleChange = ( _event: React.ChangeEvent, value: string - ) => { - this.setState({ campaignType: value as CampaignTypeValue }); - }; + ) => setPendingType(value as CampaignType); - handleSubmit = async () => { - const { campaignType } = this.state; - const { editCampaign } = this.props.mutations; - - this.setState({ isWorking: true }); + const handleSubmit = async () => { + if (pendingType === undefined) return; + setIsWorking(true); try { - const response = await editCampaign({ campaignType: campaignType! }); + const response = await editCampaignType({ + variables: { campaignId, payload: { campaignType: pendingType } } + }); if (response.errors) throw response.errors; - this.setState({ campaignType: undefined }); + setPendingType(undefined); } catch (err) { - this.props.onError(err.message); + onError((err as Error).message); } finally { - this.setState({ isWorking: false }); + setIsWorking(false); } }; - render() { - const { isWorking } = this.state; - const { - data: { campaign }, - saveLabel - } = this.props; - - const campaignType = - this.state.campaignType !== undefined - ? this.state.campaignType - : campaign.campaignType; - - const hasPendingChanges = - this.state.campaignType !== undefined && - this.state.campaignType !== campaign.campaignType; - - const isSaveDisabled = - isWorking || !hasPendingChanges || campaign.isStarted; - const finalSaveLabel = isWorking ? "Working..." : saveLabel; - - return ( -
- - - Type - - } label="SMS" /> - } label="Call" /> - - - {campaign.isStarted && ( -

- Campaign type cannot be changed after the campaign has started. -

- )} -
- -
+ return ( +
+ + + Type + + } + label="SMS" + /> + } + label="Call" + /> + + + {isLocked && ( +

+ {isStarted + ? "Campaign type cannot be changed after the campaign has started." + : "Campaign type cannot be changed after contacts have been uploaded."} +

+ )} +
+
- ); - } -} - -const queries = { - data: { - query: gql` - query getCampaignType($campaignId: String!) { - campaign(id: $campaignId) { - id - isStarted - campaignType - } - } - `, - options: (ownProps: CampaignTypeInnerProps) => ({ - variables: { - campaignId: ownProps.campaignId - } - }) - } -}; - -const mutations = { - editCampaign: (ownProps: CampaignTypeInnerProps) => ( - payload: CampaignTypeFormValues - ) => ({ - mutation: gql` - mutation editCampaignType( - $campaignId: String! - $payload: CampaignInput! - ) { - editCampaign(id: $campaignId, campaign: $payload) { - id - campaignType - } - } - `, - variables: { - campaignId: ownProps.campaignId, - payload - } - }) +
+ ); }; -export default compose( +export default compose( asSection({ title: "Campaign Type", readinessName: "campaignType", jobQueueNames: [], - expandAfterCampaignStarts: true, - expandableBySuperVolunteers: false - }), - loadData({ - queries, - mutations + expandAfterCampaignStarts: false, + expandableBySuperVolunteers: false, + avatarIcon: ({ campaignType }) => + campaignType === CampaignType.Call ? : }) )(CampaignTypeForm); diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 0e7a0b38f..671bf8cf0 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -492,7 +492,7 @@ export const resolvers = { "autosendLimit", "columnMapping" ]), - campaignType: (campaign) => (campaign.type ?? "sms").toUpperCase(), + campaignType: (campaign) => campaign.type.toUpperCase(), isApproved: (campaign) => isNil(campaign.is_approved) ? false : campaign.is_approved, isTemplate: (campaign) => diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 344af9356..20f0d54db 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -2,7 +2,8 @@ import type { Campaign, CampaignInput, - CampaignsFilter + CampaignsFilter, + CampaignType } from "@spoke/spoke-codegen"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; @@ -544,7 +545,7 @@ export const editCampaign = async ( external_system_id: externalSystemId, messaging_service_sid: messagingServiceSid ?? undefined, type: campaignType - ? (campaignType.toLowerCase() as "sms" | "call") + ? (campaignType.toLowerCase() as Lowercase) : undefined }; diff --git a/src/server/api/types.ts b/src/server/api/types.ts index bfc8097d5..ec404f67e 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -1,3 +1,4 @@ +import type { CampaignType } from "@spoke/spoke-codegen"; import type { JobHelpers } from "graphile-worker"; export enum ActionType { @@ -126,7 +127,7 @@ export interface CampaignRecord { messaging_service_sid: string | null; column_mapping: string | null; contacts_filename: string | null; - type: "sms" | "call"; + type: Lowercase; } export interface CampaignVariableRecord { From 552c0f051e400f1ed5900204eedba102edc5e014 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Mon, 8 Jun 2026 13:49:11 -0700 Subject: [PATCH 05/10] chore(dialer): address new table feedback --- .../20260601000001_add-campaign-type.js | 178 +++++++- ...01000002_create-dialer-campaign-contact.js | 81 ++-- .../20260601000003_create-dialer-call.js | 4 +- ...1000004_create-dialer-question-response.js | 17 +- ...0005_create-dialer-campaign-contact-tag.js | 30 +- schema-dump.sql | 392 +++++++++++++++++- 6 files changed, 656 insertions(+), 46 deletions(-) diff --git a/migrations/20260601000001_add-campaign-type.js b/migrations/20260601000001_add-campaign-type.js index cd4d3e912..de9cd3a68 100644 --- a/migrations/20260601000001_add-campaign-type.js +++ b/migrations/20260601000001_add-campaign-type.js @@ -24,11 +24,42 @@ exports.up = async function up(knex) { check (type <> 'call' or replies_stale_after_minutes is null); `); - // NOTE: the `campaign` view is intentionally NOT recreated to expose `type`. - // Every read of campaignType goes through the single-campaign loader, which - // reads all_campaign directly. If a campaigns-list/conversations query ever - // needs campaignType, expose `type` on the `campaign` view (or read - // all_campaign in that query) at that point. + // Expose type on the campaign view so read-replica clients querying campaign + // (rather than all_campaign directly) can see it. create or replace view can + // add a column at the end without needing to drop dependent views. + await knex.raw(` + create or replace view campaign as + select + id, + organization_id, + title, + description, + is_started, + due_by, + created_at, + is_archived, + logo_image_url, + intro_html, + primary_color, + texting_hours_start, + texting_hours_end, + timezone, + creator_id, + is_autoassign_enabled, + limit_assignment_to_teams, + updated_at, + replies_stale_after_minutes, + landlines_filtered, + external_system_id, + is_approved, + autosend_status, + autosend_user_id, + messaging_service_sid, + autosend_limit, + type + from all_campaign + where is_template = false; + `); }; /** @@ -42,6 +73,143 @@ exports.down = async function down(knex) { drop constraint if exists call_campaigns_no_autoassign, drop constraint if exists call_campaigns_no_stale_release; `); + + // Remove type from the campaign view before dropping the column. + // create or replace view cannot remove columns, so we must drop and recreate + // the view and all views that depend on it. + await knex.raw(` + drop view if exists + autosend_campaigns_to_send, + assignable_needs_reply_with_escalation_tags, + assignable_campaigns_with_needs_reply, + assignable_campaigns_with_needs_message, + assignable_needs_reply, + assignable_needs_message, + assignable_campaigns, + sendable_campaigns, + campaign; + + create view campaign as + select + id, organization_id, title, description, is_started, due_by, created_at, + is_archived, logo_image_url, intro_html, primary_color, texting_hours_start, + texting_hours_end, timezone, creator_id, is_autoassign_enabled, + limit_assignment_to_teams, updated_at, replies_stale_after_minutes, + landlines_filtered, external_system_id, is_approved, autosend_status, + autosend_user_id, messaging_service_sid, autosend_limit + from all_campaign + where is_template = false; + + create view sendable_campaigns as + select campaign.id, campaign.title, campaign.organization_id, + campaign.limit_assignment_to_teams, campaign.autosend_status, + campaign.is_autoassign_enabled + from campaign + where campaign.is_started and not campaign.is_archived; + + create view assignable_campaigns as + select sendable_campaigns.id, sendable_campaigns.title, + sendable_campaigns.organization_id, + sendable_campaigns.limit_assignment_to_teams, + sendable_campaigns.autosend_status + from sendable_campaigns + where sendable_campaigns.is_autoassign_enabled; + + create view assignable_needs_message as + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts acc + join campaign on campaign.id = acc.campaign_id + where acc.message_status = 'needsMessage' + and ( + (acc.contact_timezone is null + and extract(hour from current_timestamp at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from current_timestamp at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or ( + campaign.texting_hours_end > extract(hour from (current_timestamp at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (current_timestamp at time zone acc.contact_timezone)) + ) + ); + + create view assignable_campaigns_with_needs_message as + select assignable_campaigns.id, assignable_campaigns.title, + assignable_campaigns.organization_id, + assignable_campaigns.limit_assignment_to_teams, + assignable_campaigns.autosend_status + from assignable_campaigns + where exists ( + select 1 from assignable_needs_message + where assignable_needs_message.campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (campaign.due_by + interval '24 hours') at time zone campaign.timezone) + ) + and assignable_campaigns.autosend_status <> 'sending'; + + create view assignable_needs_reply as + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts acc + join campaign on campaign.id = acc.campaign_id + where acc.message_status = 'needsResponse' + and ( + (acc.contact_timezone is null + and extract(hour from current_timestamp at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from current_timestamp at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or ( + campaign.texting_hours_end > extract(hour from (current_timestamp at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (current_timestamp at time zone acc.contact_timezone)) + ) + ); + + create view assignable_campaigns_with_needs_reply as + select assignable_campaigns.id, assignable_campaigns.title, + assignable_campaigns.organization_id, + assignable_campaigns.limit_assignment_to_teams, + assignable_campaigns.autosend_status + from assignable_campaigns + where exists ( + select 1 from assignable_needs_reply + where assignable_needs_reply.campaign_id = assignable_campaigns.id + ); + + create view assignable_needs_reply_with_escalation_tags as + select acc.id, acc.campaign_id, acc.message_status, acc.applied_escalation_tags + from assignable_campaign_contacts_with_escalation_tags acc + join campaign on campaign.id = acc.campaign_id + where acc.message_status = 'needsResponse' + and ( + (acc.contact_timezone is null + and extract(hour from current_timestamp at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from current_timestamp at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or ( + campaign.texting_hours_end > extract(hour from (current_timestamp at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (current_timestamp at time zone acc.contact_timezone)) + ) + ); + + create view autosend_campaigns_to_send as + select sendable_campaigns.id, sendable_campaigns.title, + sendable_campaigns.organization_id, + sendable_campaigns.limit_assignment_to_teams, + sendable_campaigns.autosend_status, + sendable_campaigns.is_autoassign_enabled + from sendable_campaigns + where exists ( + select 1 from assignable_needs_message + where assignable_needs_message.campaign_id = sendable_campaigns.id + ) + and not exists ( + select 1 from campaign + where campaign.id = sendable_campaigns.id + and now() > date_trunc('day', (campaign.due_by + interval '24 hours') at time zone campaign.timezone) + ) + and sendable_campaigns.autosend_status = 'sending'; + `); + await knex.schema.alterTable("all_campaign", (table) => { table.dropColumn("type"); }); diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js index 54499b71c..4f98be364 100644 --- a/migrations/20260601000002_create-dialer-campaign-contact.js +++ b/migrations/20260601000002_create-dialer-campaign-contact.js @@ -4,24 +4,23 @@ * (message_status, is_opted_out, autosend). Reuses the shared `assignment` table * via assignment_id so the existing assignment plumbing still applies. * + * * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.up = function up(knex) { - return knex.schema.createTable("dialer_campaign_contact", (table) => { +exports.up = async function up(knex) { + await knex.schema.createTable("dialer_campaign_contact", (table) => { table.increments("id").primary(); table .integer("campaign_id") .notNullable() .references("id") - .inTable("all_campaign") - .onDelete("CASCADE"); + .inTable("all_campaign"); table .integer("assignment_id") .nullable() .references("id") - .inTable("assignment") - .onDelete("SET NULL"); + .inTable("assignment"); // Contact identity (owned here, not via campaign_contact) table.text("external_id"); table.text("first_name").notNullable(); @@ -29,35 +28,67 @@ exports.up = function up(knex) { table.text("cell").notNullable(); table.text("zip"); table.text("timezone"); - table.jsonb("custom_fields").notNullable().defaultTo("{}"); + table.text("custom_fields").notNullable().defaultTo("{}"); // Dialer state - table - .enu("call_status", [ - "not_attempted", - "queued", - "in_progress", - "completed", - "no_answer", - "voicemail", - "error" - ]) - .notNullable() - .defaultTo("not_attempted"); table.boolean("do_not_call").notNullable().defaultTo(false); - table.integer("attempt_count").notNullable().defaultTo(0); - table.timestamp("last_attempted_at").nullable(); table.boolean("archived").notNullable().defaultTo(false); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); table.timestamp("updated_at").defaultTo(knex.fn.now()).notNullable(); - table.index("campaign_id"); - table.index("assignment_id"); }); + + await knex.raw(` + -- Partial indexes mirror the campaign_contact pattern: only index live rows. + create index dialer_campaign_contact_campaign_id_idx + on dialer_campaign_contact (campaign_id) + where archived = false; + + create index dialer_campaign_contact_assignment_id_idx + on dialer_campaign_contact (assignment_id) + where archived = false; + + -- Mirrors todos_partial_idx on campaign_contact for fast "next contact to call" + -- queries by campaign, assignment, and do_not_call status. + create index dialer_campaign_contact_todos_partial_idx + on dialer_campaign_contact (campaign_id, assignment_id, do_not_call) + where archived = false; + + -- One phone number per campaign (mirrors campaign_contact's cell+campaign_id unique constraint). + alter table dialer_campaign_contact + add constraint dialer_campaign_contact_cell_campaign_id_unique unique (cell, campaign_id); + + create trigger _500_dialer_campaign_contact_updated_at + before update + on dialer_campaign_contact + for each row + execute procedure universal_updated_at(); + + create or replace function cascade_archived_to_dialer_campaign_contacts() returns trigger as $$ + begin + update dialer_campaign_contact + set archived = NEW.is_archived + where campaign_id = NEW.id; + return NEW; + end; + $$ language plpgsql strict set search_path from current; + + create trigger _500_cascade_archived_dialer_campaign + after update + on all_campaign + for each row + when (NEW.is_archived is distinct from OLD.is_archived) + execute procedure cascade_archived_to_dialer_campaign_contacts(); + `); }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.down = function down(knex) { - return knex.schema.dropTable("dialer_campaign_contact"); +exports.down = async function down(knex) { + await knex.raw(` + drop trigger if exists _500_cascade_archived_dialer_campaign on all_campaign; + drop function if exists cascade_archived_to_dialer_campaign_contacts; + drop trigger if exists _500_dialer_campaign_contact_updated_at on dialer_campaign_contact; + `); + await knex.schema.dropTable("dialer_campaign_contact"); }; diff --git a/migrations/20260601000003_create-dialer-call.js b/migrations/20260601000003_create-dialer-call.js index f74befc9a..4329f9e8b 100644 --- a/migrations/20260601000003_create-dialer-call.js +++ b/migrations/20260601000003_create-dialer-call.js @@ -13,8 +13,7 @@ exports.up = function up(knex) { .integer("dialer_campaign_contact_id") .notNullable() .references("id") - .inTable("dialer_campaign_contact") - .onDelete("CASCADE"); + .inTable("dialer_campaign_contact"); table.integer("user_id").notNullable().references("id").inTable("user"); table.text("telnyx_call_control_id").nullable(); table.text("from_number").nullable(); @@ -30,7 +29,6 @@ exports.up = function up(knex) { ]) .notNullable() .defaultTo("QUEUED"); - table.text("disposition").nullable(); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); table.timestamp("ended_at").nullable(); table.index("dialer_campaign_contact_id"); diff --git a/migrations/20260601000004_create-dialer-question-response.js b/migrations/20260601000004_create-dialer-question-response.js index b2f57ec4f..67ebc6b5b 100644 --- a/migrations/20260601000004_create-dialer-question-response.js +++ b/migrations/20260601000004_create-dialer-question-response.js @@ -33,6 +33,18 @@ exports.up = async function up(knex) { create unique index dialer_qr_step_contact_idx on dialer_question_response (interaction_step_id, dialer_campaign_contact_id) where is_deleted = false; + + create index dialer_qr_interaction_step_id_idx + on dialer_question_response (interaction_step_id); + + create index dialer_qr_is_deleted_idx + on dialer_question_response (is_deleted); + + create trigger _500_dialer_question_response_updated_at + before update + on dialer_question_response + for each row + execute procedure universal_updated_at(); `); }; @@ -40,6 +52,9 @@ exports.up = async function up(knex) { * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.down = function down(knex) { +exports.down = async function down(knex) { + await knex.raw(` + drop trigger if exists _500_dialer_question_response_updated_at on dialer_question_response; + `); return knex.schema.dropTable("dialer_question_response"); }; diff --git a/migrations/20260601000005_create-dialer-campaign-contact-tag.js b/migrations/20260601000005_create-dialer-campaign-contact-tag.js index 99533dadc..3f3d6ce70 100644 --- a/migrations/20260601000005_create-dialer-campaign-contact-tag.js +++ b/migrations/20260601000005_create-dialer-campaign-contact-tag.js @@ -6,31 +6,39 @@ * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.up = function up(knex) { - return knex.schema.createTable("dialer_campaign_contact_tag", (table) => { +exports.up = async function up(knex) { + await knex.schema.createTable("dialer_campaign_contact_tag", (table) => { table .integer("dialer_campaign_contact_id") .notNullable() .references("id") - .inTable("dialer_campaign_contact") - .onDelete("CASCADE"); - table - .integer("tag_id") - .notNullable() - .references("id") - .inTable("all_tag") - .onDelete("CASCADE"); + .inTable("dialer_campaign_contact"); + table.integer("tag_id").notNullable().references("id").inTable("all_tag"); table.integer("tagger_id").notNullable().references("id").inTable("user"); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); table.timestamp("updated_at").defaultTo(knex.fn.now()); table.primary(["dialer_campaign_contact_id", "tag_id"]); }); + + await knex.raw(` + create index dialer_campaign_contact_tag_tag_id_idx + on dialer_campaign_contact_tag (tag_id); + + create trigger _500_dialer_campaign_contact_tag_updated_at + before update + on dialer_campaign_contact_tag + for each row + execute procedure universal_updated_at(); + `); }; /** * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.down = function down(knex) { +exports.down = async function down(knex) { + await knex.raw(` + drop trigger if exists _500_dialer_campaign_contact_tag_updated_at on dialer_campaign_contact_tag; + `); return knex.schema.dropTable("dialer_campaign_contact_tag"); }; diff --git a/schema-dump.sql b/schema-dump.sql index a09da9b67..9e1f4b7a8 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -223,6 +223,11 @@ CREATE TABLE public.all_campaign ( is_template boolean DEFAULT false NOT NULL, messaging_service_sid text, autosend_limit integer, + type text DEFAULT 'sms'::text NOT NULL, + CONSTRAINT all_campaign_type_check CHECK ((type = ANY (ARRAY['sms'::text, 'call'::text]))), + CONSTRAINT call_campaigns_no_autoassign CHECK (((type <> 'call'::text) OR (is_autoassign_enabled = false))), + CONSTRAINT call_campaigns_no_autosend CHECK (((type <> 'call'::text) OR (autosend_status = 'unstarted'::text))), + CONSTRAINT call_campaigns_no_stale_release CHECK (((type <> 'call'::text) OR (replies_stale_after_minutes IS NULL))), CONSTRAINT campaign_autosend_status_check CHECK ((autosend_status = ANY (ARRAY['unstarted'::text, 'sending'::text, 'paused'::text, 'complete'::text]))) ); @@ -291,6 +296,25 @@ CREATE FUNCTION public.cascade_archived_to_campaign_contacts() RETURNS trigger ALTER FUNCTION public.cascade_archived_to_campaign_contacts() OWNER TO postgres; +-- +-- Name: cascade_archived_to_dialer_campaign_contacts(); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.cascade_archived_to_dialer_campaign_contacts() RETURNS trigger + LANGUAGE plpgsql STRICT + SET search_path TO '$user', 'public' + AS $$ + begin + update dialer_campaign_contact + set archived = NEW.is_archived + where campaign_id = NEW.id; + return NEW; + end; + $$; + + +ALTER FUNCTION public.cascade_archived_to_dialer_campaign_contacts() OWNER TO postgres; + -- -- Name: contact_is_textable_now(text, integer, integer, boolean); Type: FUNCTION; Schema: public; Owner: postgres -- @@ -1524,7 +1548,8 @@ CREATE VIEW public.campaign AS all_campaign.autosend_status, all_campaign.autosend_user_id, all_campaign.messaging_service_sid, - all_campaign.autosend_limit + all_campaign.autosend_limit, + all_campaign.type FROM public.all_campaign WHERE (all_campaign.is_template = false); @@ -2154,6 +2179,147 @@ ALTER TABLE public.deliverability_report_id_seq OWNER TO postgres; ALTER SEQUENCE public.deliverability_report_id_seq OWNED BY public.deliverability_report.id; +-- +-- Name: dialer_call; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.dialer_call ( + id integer NOT NULL, + dialer_campaign_contact_id integer NOT NULL, + user_id integer NOT NULL, + telnyx_call_control_id text, + from_number text, + status text DEFAULT 'QUEUED'::text NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + ended_at timestamp with time zone, + CONSTRAINT dialer_call_status_check CHECK ((status = ANY (ARRAY['QUEUED'::text, 'DIALING'::text, 'IN_PROGRESS'::text, 'COMPLETED'::text, 'NO_ANSWER'::text, 'VOICEMAIL'::text, 'ERROR'::text]))) +); + + +ALTER TABLE public.dialer_call OWNER TO postgres; + +-- +-- Name: dialer_call_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.dialer_call_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dialer_call_id_seq OWNER TO postgres; + +-- +-- Name: dialer_call_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.dialer_call_id_seq OWNED BY public.dialer_call.id; + + +-- +-- Name: dialer_campaign_contact; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.dialer_campaign_contact ( + id integer NOT NULL, + campaign_id integer NOT NULL, + assignment_id integer, + external_id text, + first_name text NOT NULL, + last_name text NOT NULL, + cell text NOT NULL, + zip text, + timezone text, + custom_fields text DEFAULT '{}'::text NOT NULL, + do_not_call boolean DEFAULT false NOT NULL, + archived boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.dialer_campaign_contact OWNER TO postgres; + +-- +-- Name: dialer_campaign_contact_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.dialer_campaign_contact_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dialer_campaign_contact_id_seq OWNER TO postgres; + +-- +-- Name: dialer_campaign_contact_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.dialer_campaign_contact_id_seq OWNED BY public.dialer_campaign_contact.id; + + +-- +-- Name: dialer_campaign_contact_tag; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.dialer_campaign_contact_tag ( + dialer_campaign_contact_id integer NOT NULL, + tag_id integer NOT NULL, + tagger_id integer NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.dialer_campaign_contact_tag OWNER TO postgres; + +-- +-- Name: dialer_question_response; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.dialer_question_response ( + id integer NOT NULL, + dialer_campaign_contact_id integer NOT NULL, + interaction_step_id integer NOT NULL, + value text NOT NULL, + is_deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + +ALTER TABLE public.dialer_question_response OWNER TO postgres; + +-- +-- Name: dialer_question_response_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.dialer_question_response_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dialer_question_response_id_seq OWNER TO postgres; + +-- +-- Name: dialer_question_response_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.dialer_question_response_id_seq OWNED BY public.dialer_question_response.id; + + -- -- Name: external_activist_code; Type: TABLE; Schema: public; Owner: postgres -- @@ -3555,6 +3721,27 @@ ALTER TABLE ONLY public.canned_response ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.deliverability_report ALTER COLUMN id SET DEFAULT nextval('public.deliverability_report_id_seq'::regclass); +-- +-- Name: dialer_call id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_call ALTER COLUMN id SET DEFAULT nextval('public.dialer_call_id_seq'::regclass); + + +-- +-- Name: dialer_campaign_contact id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact ALTER COLUMN id SET DEFAULT nextval('public.dialer_campaign_contact_id_seq'::regclass); + + +-- +-- Name: dialer_question_response id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_question_response ALTER COLUMN id SET DEFAULT nextval('public.dialer_question_response_id_seq'::regclass); + + -- -- Name: filtered_contact id; Type: DEFAULT; Schema: public; Owner: postgres -- @@ -3885,6 +4072,46 @@ ALTER TABLE ONLY public.deliverability_report ADD CONSTRAINT deliverability_report_pkey PRIMARY KEY (id); +-- +-- Name: dialer_call dialer_call_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_call + ADD CONSTRAINT dialer_call_pkey PRIMARY KEY (id); + + +-- +-- Name: dialer_campaign_contact dialer_campaign_contact_cell_campaign_id_unique; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact + ADD CONSTRAINT dialer_campaign_contact_cell_campaign_id_unique UNIQUE (cell, campaign_id); + + +-- +-- Name: dialer_campaign_contact dialer_campaign_contact_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact + ADD CONSTRAINT dialer_campaign_contact_pkey PRIMARY KEY (id); + + +-- +-- Name: dialer_campaign_contact_tag dialer_campaign_contact_tag_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact_tag + ADD CONSTRAINT dialer_campaign_contact_tag_pkey PRIMARY KEY (dialer_campaign_contact_id, tag_id); + + +-- +-- Name: dialer_question_response dialer_question_response_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_question_response + ADD CONSTRAINT dialer_question_response_pkey PRIMARY KEY (id); + + -- -- Name: user email_unique; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -4517,6 +4744,69 @@ CREATE INDEX deliverability_report_period_starts_at_index ON public.deliverabili CREATE INDEX deliverability_report_url_path_index ON public.deliverability_report USING btree (url_path); +-- +-- Name: dialer_call_dialer_campaign_contact_id_index; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_call_dialer_campaign_contact_id_index ON public.dialer_call USING btree (dialer_campaign_contact_id); + + +-- +-- Name: dialer_campaign_contact_assignment_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_campaign_contact_assignment_id_idx ON public.dialer_campaign_contact USING btree (assignment_id) WHERE (archived = false); + + +-- +-- Name: dialer_campaign_contact_campaign_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_campaign_contact_campaign_id_idx ON public.dialer_campaign_contact USING btree (campaign_id) WHERE (archived = false); + + +-- +-- Name: dialer_campaign_contact_tag_tag_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_campaign_contact_tag_tag_id_idx ON public.dialer_campaign_contact_tag USING btree (tag_id); + + +-- +-- Name: dialer_campaign_contact_todos_partial_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_campaign_contact_todos_partial_idx ON public.dialer_campaign_contact USING btree (campaign_id, assignment_id, do_not_call) WHERE (archived = false); + + +-- +-- Name: dialer_qr_interaction_step_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_qr_interaction_step_id_idx ON public.dialer_question_response USING btree (interaction_step_id); + + +-- +-- Name: dialer_qr_is_deleted_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_qr_is_deleted_idx ON public.dialer_question_response USING btree (is_deleted); + + +-- +-- Name: dialer_qr_step_contact_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX dialer_qr_step_contact_idx ON public.dialer_question_response USING btree (interaction_step_id, dialer_campaign_contact_id) WHERE (is_deleted = false); + + +-- +-- Name: dialer_question_response_dialer_campaign_contact_id_index; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_question_response_dialer_campaign_contact_id_index ON public.dialer_question_response USING btree (dialer_campaign_contact_id); + + -- -- Name: filtered_contact_campaign_id_index; Type: INDEX; Schema: public; Owner: postgres -- @@ -4972,6 +5262,34 @@ CREATE TRIGGER _500_canned_response_updated_at BEFORE UPDATE ON public.canned_re CREATE TRIGGER _500_cascade_archived_campaign AFTER UPDATE ON public.all_campaign FOR EACH ROW WHEN ((new.is_archived IS DISTINCT FROM old.is_archived)) EXECUTE FUNCTION public.cascade_archived_to_campaign_contacts(); +-- +-- Name: all_campaign _500_cascade_archived_dialer_campaign; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER _500_cascade_archived_dialer_campaign AFTER UPDATE ON public.all_campaign FOR EACH ROW WHEN ((new.is_archived IS DISTINCT FROM old.is_archived)) EXECUTE FUNCTION public.cascade_archived_to_dialer_campaign_contacts(); + + +-- +-- Name: dialer_campaign_contact_tag _500_dialer_campaign_contact_tag_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER _500_dialer_campaign_contact_tag_updated_at BEFORE UPDATE ON public.dialer_campaign_contact_tag FOR EACH ROW EXECUTE FUNCTION public.universal_updated_at(); + + +-- +-- Name: dialer_campaign_contact _500_dialer_campaign_contact_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER _500_dialer_campaign_contact_updated_at BEFORE UPDATE ON public.dialer_campaign_contact FOR EACH ROW EXECUTE FUNCTION public.universal_updated_at(); + + +-- +-- Name: dialer_question_response _500_dialer_question_response_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER _500_dialer_question_response_updated_at BEFORE UPDATE ON public.dialer_question_response FOR EACH ROW EXECUTE FUNCTION public.universal_updated_at(); + + -- -- Name: external_activist_code _500_external_activist_code_updated_at; Type: TRIGGER; Schema: public; Owner: postgres -- @@ -5381,6 +5699,78 @@ ALTER TABLE ONLY public.canned_response ADD CONSTRAINT canned_response_user_id_foreign FOREIGN KEY (user_id) REFERENCES public."user"(id); +-- +-- Name: dialer_call dialer_call_dialer_campaign_contact_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_call + ADD CONSTRAINT dialer_call_dialer_campaign_contact_id_foreign FOREIGN KEY (dialer_campaign_contact_id) REFERENCES public.dialer_campaign_contact(id); + + +-- +-- Name: dialer_call dialer_call_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_call + ADD CONSTRAINT dialer_call_user_id_foreign FOREIGN KEY (user_id) REFERENCES public."user"(id); + + +-- +-- Name: dialer_campaign_contact dialer_campaign_contact_assignment_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact + ADD CONSTRAINT dialer_campaign_contact_assignment_id_foreign FOREIGN KEY (assignment_id) REFERENCES public.assignment(id); + + +-- +-- Name: dialer_campaign_contact dialer_campaign_contact_campaign_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact + ADD CONSTRAINT dialer_campaign_contact_campaign_id_foreign FOREIGN KEY (campaign_id) REFERENCES public.all_campaign(id); + + +-- +-- Name: dialer_campaign_contact_tag dialer_campaign_contact_tag_dialer_campaign_contact_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact_tag + ADD CONSTRAINT dialer_campaign_contact_tag_dialer_campaign_contact_id_foreign FOREIGN KEY (dialer_campaign_contact_id) REFERENCES public.dialer_campaign_contact(id); + + +-- +-- Name: dialer_campaign_contact_tag dialer_campaign_contact_tag_tag_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact_tag + ADD CONSTRAINT dialer_campaign_contact_tag_tag_id_foreign FOREIGN KEY (tag_id) REFERENCES public.all_tag(id); + + +-- +-- Name: dialer_campaign_contact_tag dialer_campaign_contact_tag_tagger_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_campaign_contact_tag + ADD CONSTRAINT dialer_campaign_contact_tag_tagger_id_foreign FOREIGN KEY (tagger_id) REFERENCES public."user"(id); + + +-- +-- Name: dialer_question_response dialer_question_response_dialer_campaign_contact_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_question_response + ADD CONSTRAINT dialer_question_response_dialer_campaign_contact_id_foreign FOREIGN KEY (dialer_campaign_contact_id) REFERENCES public.dialer_campaign_contact(id) ON DELETE CASCADE; + + +-- +-- Name: dialer_question_response dialer_question_response_interaction_step_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.dialer_question_response + ADD CONSTRAINT dialer_question_response_interaction_step_id_foreign FOREIGN KEY (interaction_step_id) REFERENCES public.interaction_step(id) ON DELETE CASCADE; + + -- -- Name: external_activist_code external_activist_code_system_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres -- From 27c719f0c50c23ce33a921c50dea89c5bfa729f3 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Mon, 8 Jun 2026 14:08:55 -0700 Subject: [PATCH 06/10] chore(dialer): address feedback --- migrations/20260601000003_create-dialer-call.js | 9 ++++++++- .../20260601000004_create-dialer-question-response.js | 6 ++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/migrations/20260601000003_create-dialer-call.js b/migrations/20260601000003_create-dialer-call.js index 4329f9e8b..094c26c4e 100644 --- a/migrations/20260601000003_create-dialer-call.js +++ b/migrations/20260601000003_create-dialer-call.js @@ -31,7 +31,14 @@ exports.up = function up(knex) { .defaultTo("QUEUED"); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); table.timestamp("ended_at").nullable(); - table.index("dialer_campaign_contact_id"); + // Mirror message's indexes: one for lookups by contact, one for recent-call + // queries. The composite (contact_id, status) index also covers the NOT EXISTS + // subqueries that filter callable contacts by active/terminal call status. + table.index( + ["dialer_campaign_contact_id", "status"], + "dialer_call_contact_status_idx" + ); + table.index("created_at", "dialer_call_created_at_idx"); }); }; diff --git a/migrations/20260601000004_create-dialer-question-response.js b/migrations/20260601000004_create-dialer-question-response.js index 67ebc6b5b..d8b179c48 100644 --- a/migrations/20260601000004_create-dialer-question-response.js +++ b/migrations/20260601000004_create-dialer-question-response.js @@ -13,14 +13,12 @@ exports.up = async function up(knex) { .integer("dialer_campaign_contact_id") .notNullable() .references("id") - .inTable("dialer_campaign_contact") - .onDelete("CASCADE"); + .inTable("dialer_campaign_contact"); table .integer("interaction_step_id") .notNullable() .references("id") - .inTable("interaction_step") - .onDelete("CASCADE"); + .inTable("interaction_step"); table.text("value").notNullable(); table.boolean("is_deleted").notNullable().defaultTo(false); table.timestamp("created_at").defaultTo(knex.fn.now()).notNullable(); From 4243fe289729fff58c40b70a571ae5999b0ab6e7 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Mon, 8 Jun 2026 14:14:18 -0700 Subject: [PATCH 07/10] chore(dialer): regenerate schema dump --- schema-dump.sql | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/schema-dump.sql b/schema-dump.sql index 9e1f4b7a8..bebe2342f 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -4745,10 +4745,17 @@ CREATE INDEX deliverability_report_url_path_index ON public.deliverability_repor -- --- Name: dialer_call_dialer_campaign_contact_id_index; Type: INDEX; Schema: public; Owner: postgres +-- Name: dialer_call_contact_status_idx; Type: INDEX; Schema: public; Owner: postgres -- -CREATE INDEX dialer_call_dialer_campaign_contact_id_index ON public.dialer_call USING btree (dialer_campaign_contact_id); +CREATE INDEX dialer_call_contact_status_idx ON public.dialer_call USING btree (dialer_campaign_contact_id, status); + + +-- +-- Name: dialer_call_created_at_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_call_created_at_idx ON public.dialer_call USING btree (created_at); -- @@ -5760,7 +5767,7 @@ ALTER TABLE ONLY public.dialer_campaign_contact_tag -- ALTER TABLE ONLY public.dialer_question_response - ADD CONSTRAINT dialer_question_response_dialer_campaign_contact_id_foreign FOREIGN KEY (dialer_campaign_contact_id) REFERENCES public.dialer_campaign_contact(id) ON DELETE CASCADE; + ADD CONSTRAINT dialer_question_response_dialer_campaign_contact_id_foreign FOREIGN KEY (dialer_campaign_contact_id) REFERENCES public.dialer_campaign_contact(id); -- @@ -5768,7 +5775,7 @@ ALTER TABLE ONLY public.dialer_question_response -- ALTER TABLE ONLY public.dialer_question_response - ADD CONSTRAINT dialer_question_response_interaction_step_id_foreign FOREIGN KEY (interaction_step_id) REFERENCES public.interaction_step(id) ON DELETE CASCADE; + ADD CONSTRAINT dialer_question_response_interaction_step_id_foreign FOREIGN KEY (interaction_step_id) REFERENCES public.interaction_step(id); -- From aea9afb2f269468790d7792cb32d031f45734c2b Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Wed, 10 Jun 2026 06:22:34 -0700 Subject: [PATCH 08/10] chore(dialer): address feedback --- .../20260601000002_create-dialer-campaign-contact.js | 10 ++++++---- ...0260601000005_create-dialer-campaign-contact-tag.js | 5 +++++ schema-dump.sql | 9 ++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js index 4f98be364..cd05cc510 100644 --- a/migrations/20260601000002_create-dialer-campaign-contact.js +++ b/migrations/20260601000002_create-dialer-campaign-contact.js @@ -37,11 +37,13 @@ exports.up = async function up(knex) { }); await knex.raw(` - -- Partial indexes mirror the campaign_contact pattern: only index live rows. + -- Full index on campaign_id: archived contacts are still queried by campaign + -- for contact counts and overlap checks. create index dialer_campaign_contact_campaign_id_idx - on dialer_campaign_contact (campaign_id) - where archived = false; + on dialer_campaign_contact (campaign_id); + -- Partial index on assignment_id: only active (non-archived) contacts are + -- ever looked up by assignment. create index dialer_campaign_contact_assignment_id_idx on dialer_campaign_contact (assignment_id) where archived = false; @@ -52,7 +54,7 @@ exports.up = async function up(knex) { on dialer_campaign_contact (campaign_id, assignment_id, do_not_call) where archived = false; - -- One phone number per campaign (mirrors campaign_contact's cell+campaign_id unique constraint). + -- Each contact (identified by cell) should only appear once per campaign (mirrors campaign_contact's cell+campaign_id unique constraint). alter table dialer_campaign_contact add constraint dialer_campaign_contact_cell_campaign_id_unique unique (cell, campaign_id); diff --git a/migrations/20260601000005_create-dialer-campaign-contact-tag.js b/migrations/20260601000005_create-dialer-campaign-contact-tag.js index 3f3d6ce70..05a57c728 100644 --- a/migrations/20260601000005_create-dialer-campaign-contact-tag.js +++ b/migrations/20260601000005_create-dialer-campaign-contact-tag.js @@ -21,6 +21,9 @@ exports.up = async function up(knex) { }); await knex.raw(` + create index dialer_campaign_contact_tag_contact_idx + on dialer_campaign_contact_tag (dialer_campaign_contact_id); + create index dialer_campaign_contact_tag_tag_id_idx on dialer_campaign_contact_tag (tag_id); @@ -39,6 +42,8 @@ exports.up = async function up(knex) { exports.down = async function down(knex) { await knex.raw(` drop trigger if exists _500_dialer_campaign_contact_tag_updated_at on dialer_campaign_contact_tag; + drop index if exists dialer_campaign_contact_tag_contact_idx; + drop index if exists dialer_campaign_contact_tag_tag_id_idx; `); return knex.schema.dropTable("dialer_campaign_contact_tag"); }; diff --git a/schema-dump.sql b/schema-dump.sql index bebe2342f..6f1c2258d 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -4769,7 +4769,14 @@ CREATE INDEX dialer_campaign_contact_assignment_id_idx ON public.dialer_campaign -- Name: dialer_campaign_contact_campaign_id_idx; Type: INDEX; Schema: public; Owner: postgres -- -CREATE INDEX dialer_campaign_contact_campaign_id_idx ON public.dialer_campaign_contact USING btree (campaign_id) WHERE (archived = false); +CREATE INDEX dialer_campaign_contact_campaign_id_idx ON public.dialer_campaign_contact USING btree (campaign_id); + + +-- +-- Name: dialer_campaign_contact_tag_contact_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX dialer_campaign_contact_tag_contact_idx ON public.dialer_campaign_contact_tag USING btree (dialer_campaign_contact_id); -- From 50b2306d2de20825a0066066bb52ee2bfdbe2edd Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Tue, 16 Jun 2026 10:14:20 -0700 Subject: [PATCH 09/10] feat(dialer): volunteer caller backend (#205) * feat(dialer): volunteer caller backend (Design-B call tracking) Backend for the volunteer dialer, stacked on dialer-campaign-type: - GraphQL schema, resolvers, and Telnyx WebRTC token route - dialer lib: shift assignment, contact-hours-aware serving, atomic call_status claim, disposition + attempt tracking - forward migrations (campaign-type's create migrations left untouched): - 000009: drop call_campaigns_no_autoassign (call campaigns use autoassign for shifts) - 000010: add call_status/attempt_count/last_attempted_at to dialer_campaign_contact and disposition to dialer_call Co-Authored-By: Claude Opus 4.8 * chore(dialer): address feedback * feat(dialer): volunteer caller frontend (Telnyx WebRTC UI) (#207) * chore(tool-versions): update node version (#195) * feat(dialer): volunteer caller frontend (Telnyx WebRTC UI) Frontend for the volunteer dialer, stacked on dialer-backend: - VolunteerDialer container: WebRTC call controls, timer, status bar, disposition form, contact flow - TexterTodoList: call-shift request entry point (CallRequest) and call-aware assignment summary - AdminCampaignStats: call-campaign stat tweaks - dialer GraphQL operations (hooks) and @telnyx/webrtc dependency Co-Authored-By: Claude Opus 4.8 * feat(dialer): add canned responses + tags to calling (#208) * feat(dialer): add canned responses + tags to calling * feat(dialer): allow releasing calls (#209) * feat(dialer): allow releasing calls * feat(dialer): add texting history to call screen (#210) * feat(dialer): add texting history to call screen * chore: update seeds to not use logger.info * chore(dialer): fix variable interpolation in script --------- Co-authored-by: Aashish John Co-authored-by: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Aashish John --- .tool-versions | 2 +- libs/gql-schema/dialer.ts | 74 ++ libs/gql-schema/schema.ts | 14 +- .../src/graphql/campaign-list.graphql | 1 + .../src/graphql/campaign-stats.graphql | 1 + libs/spoke-codegen/src/graphql/dialer.graphql | 166 ++++ ...01000002_create-dialer-campaign-contact.js | 2 +- .../20260601000003_create-dialer-call.js | 3 +- ...0005_create-dialer-campaign-contact-tag.js | 4 - .../20260601000008_dialer-call-add-timing.js | 23 + ...1000009_call-campaigns-allow-autoassign.js | 26 + ...0601000010_dialer-contact-call-tracking.js | 33 + package.json | 3 +- schema-dump.sql | 14 +- seeds/dev.js | 11 +- seeds/staging.js | 17 +- src/config.js | 20 + .../sections/CampaignTextersForm/hooks.ts | 13 +- src/containers/AdminCampaignList.jsx | 67 +- .../components/TopLineStats.jsx | 65 +- src/containers/AdminCampaignStats/index.jsx | 47 +- .../components/CampaignListMenu.tsx | 79 +- src/containers/CampaignList/utils.ts | 26 +- .../components/AssignmentSummary.tsx | 127 ++-- .../TexterTodoList/components/CallRequest.tsx | 87 +++ src/containers/TexterTodoList/index.jsx | 5 + .../VolunteerDialer/DialerContact.tsx | 710 ++++++++++++++++++ .../components/CallControls.tsx | 107 +++ .../components/CallStatusBar.tsx | 76 ++ .../VolunteerDialer/components/CallTimer.tsx | 52 ++ .../components/CannedResponses.tsx | 130 ++++ .../components/ContactHistoryPanel.tsx | 161 ++++ .../components/DispositionForm.tsx | 100 +++ .../VolunteerDialer/components/TagDialog.tsx | 80 ++ src/containers/VolunteerDialer/index.tsx | 203 +++++ .../VolunteerDialer/useTelnyxWebRTC.ts | 210 ++++++ src/routes.jsx | 7 + src/schema.graphql | 83 ++ src/server/api/assignment.js | 18 + src/server/api/campaign.js | 35 +- src/server/api/dialer.ts | 59 ++ src/server/api/lib/campaign.ts | 98 ++- src/server/api/lib/dialer.ts | 601 +++++++++++++++ src/server/api/root-mutations.ts | 162 +++- src/server/api/root-resolvers.ts | 43 +- src/server/api/schema.ts | 2 + src/server/api/types.ts | 28 + src/server/api/user.js | 43 +- src/server/app.ts | 2 + .../models/cacheable_queries/campaign.js | 16 +- src/server/models/index.ts | 30 + src/server/routes/index.ts | 2 + src/server/routes/telnyx.ts | 94 +++ src/server/send-message-errors.ts | 2 +- src/server/tasks/assign-texters.ts | 99 ++- .../import-contact-csv-from-url.ts | 51 +- yarn.lock | 24 +- 57 files changed, 3975 insertions(+), 283 deletions(-) create mode 100644 libs/gql-schema/dialer.ts create mode 100644 libs/spoke-codegen/src/graphql/dialer.graphql create mode 100644 migrations/20260601000008_dialer-call-add-timing.js create mode 100644 migrations/20260601000009_call-campaigns-allow-autoassign.js create mode 100644 migrations/20260601000010_dialer-contact-call-tracking.js create mode 100644 src/containers/TexterTodoList/components/CallRequest.tsx create mode 100644 src/containers/VolunteerDialer/DialerContact.tsx create mode 100644 src/containers/VolunteerDialer/components/CallControls.tsx create mode 100644 src/containers/VolunteerDialer/components/CallStatusBar.tsx create mode 100644 src/containers/VolunteerDialer/components/CallTimer.tsx create mode 100644 src/containers/VolunteerDialer/components/CannedResponses.tsx create mode 100644 src/containers/VolunteerDialer/components/ContactHistoryPanel.tsx create mode 100644 src/containers/VolunteerDialer/components/DispositionForm.tsx create mode 100644 src/containers/VolunteerDialer/components/TagDialog.tsx create mode 100644 src/containers/VolunteerDialer/index.tsx create mode 100644 src/containers/VolunteerDialer/useTelnyxWebRTC.ts create mode 100644 src/server/api/dialer.ts create mode 100644 src/server/api/lib/dialer.ts create mode 100644 src/server/routes/telnyx.ts diff --git a/.tool-versions b/.tool-versions index aeca3fd9b..2a49c8294 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 16.14.0 +nodejs 20.16.0 yarn 1.22.19 diff --git a/libs/gql-schema/dialer.ts b/libs/gql-schema/dialer.ts new file mode 100644 index 000000000..2c32a8c16 --- /dev/null +++ b/libs/gql-schema/dialer.ts @@ -0,0 +1,74 @@ +export const schema = ` + type DialerCampaignContact { + id: ID! + campaignId: ID! + firstName: String! + lastName: String! + zip: String + callStatus: String! + doNotCall: Boolean! + attemptCount: Int! + lastAttemptedAt: Date + customFields: JSON! + assignment: Assignment + interactionSteps: [InteractionStep!]! + questionResponseValues: [DialerQuestionResponseValue!]! + tags: [Tag!]! + campaignVariables: [CampaignVariable!]! + } + + type DialerQuestionResponseValue { + id: ID! + interactionStepId: ID! + question: String! + value: String! + } + + # A past texting conversation with the same person (matched by phone), shown + # as context on the calling screen. One entry per prior campaign_contact. + type DialerContactConversation { + campaignId: ID! + campaignTitle: String! + contactId: ID! + firstName: String + lastName: String + messages: [Message!]! + } + + type DialerCall { + id: ID! + dialerCampaignContactId: ID! + status: String! + fromNumber: String + telnyxCallControlId: String + createdAt: Date! + answeredAt: Date + endedAt: Date + } + + type InitiateCallResult { + dialerCallId: ID! + contactPhone: String! + fromNumber: String! + } + + type RequestCallShiftResult { + assignmentId: ID + campaignId: ID + count: Int! + } + + input DialerQuestionResponseInput { + interactionStepId: String! + value: String! + } + + input UpdateDialerCallInput { + status: String + telnyxCallControlId: String + answeredAt: String + endedAt: String + } +`; + +export default schema; diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 139c279ec..da0727b35 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -7,6 +7,7 @@ import { schema as campaignGroupSchema } from "./campaign-group"; import { schema as campaignVariableSchema } from "./campaign-variable"; import { schema as cannedResponseSchema } from "./canned-response"; import { schema as conversationSchema } from "./conversations"; +import { schema as dialerSchema } from "./dialer"; import { schema as externalActivistCodeSchema } from "./external-activist-code"; import { schema as externalListSchema } from "./external-list"; import { schema as externalResultCodeSchema } from "./external-result-code"; @@ -246,6 +247,10 @@ const rootSchema = ` type RootQuery { currentUser: User organization(id:String!, utc:String): Organization + getNextDialerContact(assignmentId: String!): DialerCampaignContact + getDialerContact(dialerCampaignContactId: String!): DialerCampaignContact + dialerContactTextingHistory(dialerCampaignContactId: String!): [DialerContactConversation!]! + callShiftAvailable(organizationId: String!): Boolean! campaign(id:String!): Campaign inviteByHash(hash:String!): [Invite] contact(id:String!): CampaignContact @@ -281,6 +286,12 @@ const rootSchema = ` type RootMutation { createInvite(invite:InviteInput!): Invite + initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! + updateDialerCall(dialerCallId: String!, input: UpdateDialerCallInput!): DialerCall! + saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! + markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! + tagDialerContact(dialerCampaignContactId: String!, tag: ContactTagActionInput!): DialerCampaignContact! + requestCallShift(organizationId: String!): RequestCallShiftResult! createCampaign(campaign:CampaignInput!): Campaign createTemplateCampaign(organizationId: String!): Campaign! deleteTemplateCampaign(organizationId: String!, campaignId: String!): Boolean! @@ -425,7 +436,8 @@ export const schema = [ externalResponseOptionSchema, externalActivistCodeSchema, externalResultCodeSchema, - externalSyncConfigSchema + externalSyncConfigSchema, + dialerSchema ]; export default rootSchema; diff --git a/libs/spoke-codegen/src/graphql/campaign-list.graphql b/libs/spoke-codegen/src/graphql/campaign-list.graphql index 08ffdf380..ab0b17788 100644 --- a/libs/spoke-codegen/src/graphql/campaign-list.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-list.graphql @@ -1,6 +1,7 @@ fragment CampaignListEntry on Campaign { id title + campaignType isStarted isApproved isArchived diff --git a/libs/spoke-codegen/src/graphql/campaign-stats.graphql b/libs/spoke-codegen/src/graphql/campaign-stats.graphql index eef9e5919..8f2239117 100644 --- a/libs/spoke-codegen/src/graphql/campaign-stats.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-stats.graphql @@ -18,6 +18,7 @@ query getCampaign($campaignId: String!) { campaign(id: $campaignId) { id title + campaignType dueBy isArchived isStarted diff --git a/libs/spoke-codegen/src/graphql/dialer.graphql b/libs/spoke-codegen/src/graphql/dialer.graphql new file mode 100644 index 000000000..5d1eafab5 --- /dev/null +++ b/libs/spoke-codegen/src/graphql/dialer.graphql @@ -0,0 +1,166 @@ +fragment DialerContactCore on DialerCampaignContact { + id + campaignId + firstName + lastName + zip + callStatus + doNotCall + attemptCount + lastAttemptedAt + customFields + campaignVariables { + id + name + value + } + questionResponseValues { + id + interactionStepId + question + value + } + tags { + id + title + description + confirmationSteps + onApplyScript + textColor + backgroundColor + isAssignable + isSystem + } + interactionSteps { + id + questionText + scriptOptions + answerOption + parentInteractionId + isDeleted + answerActions + question { + text + answerOptions { + value + nextInteractionStep { + id + scriptOptions + } + } + } + } +} + +query GetNextDialerContact($assignmentId: String!) { + getNextDialerContact(assignmentId: $assignmentId) { + ...DialerContactCore + } +} + +query GetDialerContact($dialerCampaignContactId: String!) { + getDialerContact(dialerCampaignContactId: $dialerCampaignContactId) { + ...DialerContactCore + } +} + +query DialerContactTextingHistory($dialerCampaignContactId: String!) { + dialerContactTextingHistory( + dialerCampaignContactId: $dialerCampaignContactId + ) { + contactId + campaignId + campaignTitle + firstName + lastName + messages { + id + text + isFromContact + createdAt + } + } +} + +mutation InitiateCall($assignmentId: String!, $dialerCampaignContactId: String!) { + initiateCall(assignmentId: $assignmentId, dialerCampaignContactId: $dialerCampaignContactId) { + dialerCallId + contactPhone + fromNumber + } +} + +mutation UpdateDialerCall( + $dialerCallId: String! + $input: UpdateDialerCallInput! +) { + updateDialerCall(dialerCallId: $dialerCallId, input: $input) { + id + status + telnyxCallControlId + answeredAt + endedAt + } +} + +mutation SaveDialerQuestionResponses( + $dialerCampaignContactId: String! + $questionResponses: [DialerQuestionResponseInput!]! +) { + saveDialerQuestionResponses( + dialerCampaignContactId: $dialerCampaignContactId + questionResponses: $questionResponses + ) { + ...DialerContactCore + } +} + +mutation MarkDialerContactComplete( + $dialerCampaignContactId: String! + $callStatus: String! +) { + markDialerContactComplete( + dialerCampaignContactId: $dialerCampaignContactId + callStatus: $callStatus + ) { + id + callStatus + attemptCount + lastAttemptedAt + } +} + +mutation TagDialerContact( + $dialerCampaignContactId: String! + $tag: ContactTagActionInput! +) { + tagDialerContact( + dialerCampaignContactId: $dialerCampaignContactId + tag: $tag + ) { + id + tags { + id + title + description + confirmationSteps + onApplyScript + textColor + backgroundColor + isAssignable + isSystem + } + } +} + +query CallShiftAvailable($organizationId: String!) { + callShiftAvailable(organizationId: $organizationId) +} + +mutation RequestCallShift($organizationId: String!) { + requestCallShift(organizationId: $organizationId) { + assignmentId + campaignId + count + } +} diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js index cd05cc510..a7568c00f 100644 --- a/migrations/20260601000002_create-dialer-campaign-contact.js +++ b/migrations/20260601000002_create-dialer-campaign-contact.js @@ -54,7 +54,7 @@ exports.up = async function up(knex) { on dialer_campaign_contact (campaign_id, assignment_id, do_not_call) where archived = false; - -- Each contact (identified by cell) should only appear once per campaign (mirrors campaign_contact's cell+campaign_id unique constraint). + -- One phone number per campaign (mirrors campaign_contact's cell+campaign_id unique constraint). alter table dialer_campaign_contact add constraint dialer_campaign_contact_cell_campaign_id_unique unique (cell, campaign_id); diff --git a/migrations/20260601000003_create-dialer-call.js b/migrations/20260601000003_create-dialer-call.js index 094c26c4e..88b2851cb 100644 --- a/migrations/20260601000003_create-dialer-call.js +++ b/migrations/20260601000003_create-dialer-call.js @@ -1,7 +1,6 @@ /** * One row per call attempt against a dialer contact. The dialer analogue of the - * `message` table. from_number records the caller ID used; - * disposition is the volunteer-recorded outcome. + * `message` table. from_number records the caller ID used. * * @param { import("knex").Knex } knex * @returns { Promise } diff --git a/migrations/20260601000005_create-dialer-campaign-contact-tag.js b/migrations/20260601000005_create-dialer-campaign-contact-tag.js index 05a57c728..fde41bb7c 100644 --- a/migrations/20260601000005_create-dialer-campaign-contact-tag.js +++ b/migrations/20260601000005_create-dialer-campaign-contact-tag.js @@ -21,9 +21,6 @@ exports.up = async function up(knex) { }); await knex.raw(` - create index dialer_campaign_contact_tag_contact_idx - on dialer_campaign_contact_tag (dialer_campaign_contact_id); - create index dialer_campaign_contact_tag_tag_id_idx on dialer_campaign_contact_tag (tag_id); @@ -42,7 +39,6 @@ exports.up = async function up(knex) { exports.down = async function down(knex) { await knex.raw(` drop trigger if exists _500_dialer_campaign_contact_tag_updated_at on dialer_campaign_contact_tag; - drop index if exists dialer_campaign_contact_tag_contact_idx; drop index if exists dialer_campaign_contact_tag_tag_id_idx; `); return knex.schema.dropTable("dialer_campaign_contact_tag"); diff --git a/migrations/20260601000008_dialer-call-add-timing.js b/migrations/20260601000008_dialer-call-add-timing.js new file mode 100644 index 000000000..2e1525fb9 --- /dev/null +++ b/migrations/20260601000008_dialer-call-add-timing.js @@ -0,0 +1,23 @@ +/** + * Record when a dialer call actually connected. Talk duration is then derived + * as ended_at - answered_at (created_at is the queue/dial-click time, which + * includes ring time, so it isn't a reliable start for talk duration). + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema.alterTable("dialer_call", (table) => { + table.timestamp("answered_at").nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable("dialer_call", (table) => { + table.dropColumn("answered_at"); + }); +}; diff --git a/migrations/20260601000009_call-campaigns-allow-autoassign.js b/migrations/20260601000009_call-campaigns-allow-autoassign.js new file mode 100644 index 000000000..9406bdb80 --- /dev/null +++ b/migrations/20260601000009_call-campaigns-allow-autoassign.js @@ -0,0 +1,26 @@ +/** + * Call campaigns DO use autoassignment — it's how volunteers are handed shifts + * of dialer contacts to call. Drop the guard added in 20260601000001 that + * pinned is_autoassign_enabled = false for call campaigns. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.raw(` + alter table all_campaign + drop constraint if exists call_campaigns_no_autoassign; + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.raw(` + alter table all_campaign + add constraint call_campaigns_no_autoassign + check (type <> 'call' or is_autoassign_enabled = false); + `); +}; diff --git a/migrations/20260601000010_dialer-contact-call-tracking.js b/migrations/20260601000010_dialer-contact-call-tracking.js new file mode 100644 index 000000000..88a22b3b7 --- /dev/null +++ b/migrations/20260601000010_dialer-contact-call-tracking.js @@ -0,0 +1,33 @@ +/** + * Per-contact call tracking for the dialer. call_status drives the + * "next contact to serve" queries (not_attempted / no_answer are callable) and + * records the volunteer's final disposition; attempt_count and last_attempted_at + * record dialing history. + * + * call_status is a plain text column rather than an enum: the allowed values are + * driven by the volunteer-facing disposition list, which is still evolving. + * Current values: not_attempted (default), in_progress, answered, no_answer, + * voicemail, busy, do_not_call. + * + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema.alterTable("dialer_campaign_contact", (table) => { + table.text("call_status").notNullable().defaultTo("not_attempted"); + table.integer("attempt_count").notNullable().defaultTo(0); + table.timestamp("last_attempted_at").nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + await knex.schema.alterTable("dialer_campaign_contact", (table) => { + table.dropColumn("call_status"); + table.dropColumn("attempt_count"); + table.dropColumn("last_attempted_at"); + }); +}; diff --git a/package.json b/package.json index 42f00d792..ba350b2be 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@rewired/passport-slack": "^1.0.6", "@rooks/use-intersection-observer-ref": "^4.11.2", "@slack/web-api": "^6.0.0", + "@telnyx/webrtc": "^2.27.1", "@trt2/gsm-charset-utils": "^1.0.13", "@types/jest": "^27.4.0", "aphrodite": "^2.4.0", @@ -181,8 +182,8 @@ "request": "^2.81.0", "rethink-knex-adapter": "^0.4.17", "style-loader": "^2.0.0", - "switchboard-client": "^1.4.1", "superagent": "^4.1.0", + "switchboard-client": "^1.4.1", "thinky": "^2.3.3", "timezonecomplete": "^5.11.0", "twilio": "^2.11.0", diff --git a/schema-dump.sql b/schema-dump.sql index 6f1c2258d..b78331a34 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -225,7 +225,6 @@ CREATE TABLE public.all_campaign ( autosend_limit integer, type text DEFAULT 'sms'::text NOT NULL, CONSTRAINT all_campaign_type_check CHECK ((type = ANY (ARRAY['sms'::text, 'call'::text]))), - CONSTRAINT call_campaigns_no_autoassign CHECK (((type <> 'call'::text) OR (is_autoassign_enabled = false))), CONSTRAINT call_campaigns_no_autosend CHECK (((type <> 'call'::text) OR (autosend_status = 'unstarted'::text))), CONSTRAINT call_campaigns_no_stale_release CHECK (((type <> 'call'::text) OR (replies_stale_after_minutes IS NULL))), CONSTRAINT campaign_autosend_status_check CHECK ((autosend_status = ANY (ARRAY['unstarted'::text, 'sending'::text, 'paused'::text, 'complete'::text]))) @@ -2192,6 +2191,7 @@ CREATE TABLE public.dialer_call ( status text DEFAULT 'QUEUED'::text NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, ended_at timestamp with time zone, + answered_at timestamp with time zone, CONSTRAINT dialer_call_status_check CHECK ((status = ANY (ARRAY['QUEUED'::text, 'DIALING'::text, 'IN_PROGRESS'::text, 'COMPLETED'::text, 'NO_ANSWER'::text, 'VOICEMAIL'::text, 'ERROR'::text]))) ); @@ -2238,7 +2238,10 @@ CREATE TABLE public.dialer_campaign_contact ( do_not_call boolean DEFAULT false NOT NULL, archived boolean DEFAULT false NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + call_status text DEFAULT 'not_attempted'::text NOT NULL, + attempt_count integer DEFAULT 0 NOT NULL, + last_attempted_at timestamp with time zone ); @@ -4772,13 +4775,6 @@ CREATE INDEX dialer_campaign_contact_assignment_id_idx ON public.dialer_campaign CREATE INDEX dialer_campaign_contact_campaign_id_idx ON public.dialer_campaign_contact USING btree (campaign_id); --- --- Name: dialer_campaign_contact_tag_contact_idx; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE INDEX dialer_campaign_contact_tag_contact_idx ON public.dialer_campaign_contact_tag USING btree (dialer_campaign_contact_id); - - -- -- Name: dialer_campaign_contact_tag_tag_id_idx; Type: INDEX; Schema: public; Owner: postgres -- diff --git a/seeds/dev.js b/seeds/dev.js index 42f262a41..0f0eb115c 100644 --- a/seeds/dev.js +++ b/seeds/dev.js @@ -1,10 +1,3 @@ -let logger; -try { - logger = require("../src/logger"); -} catch { - logger = require(`${__dirname}/../build/src/logger`); -} - // ── Campaign stats seed constants ───────────────────────────────────────────── const CAMPAIGN_ID = 1; const ORGANIZATION_ID = 1; @@ -53,7 +46,7 @@ exports.seed = async function seed(knex) { ); } - logger.info("Starting dev seed (campaign stats data)..."); + console.log("Starting dev seed (campaign stats data)..."); // Clear existing question responses and interaction steps for this campaign await knex("all_question_response") @@ -277,7 +270,7 @@ exports.seed = async function seed(knex) { ).length; const replies = messageRows.filter((m) => m.is_from_contact).length; - logger.info( + console.log( `Dev seed complete — contacts: ${insertedContacts.length}, sent: ${sent}, replies: ${replies}, opt-outs: ${optOutRows.length}, survey responses: ${questionResponseRows.length}` ); }; diff --git a/seeds/staging.js b/seeds/staging.js index 7afdddf88..cf0060eba 100644 --- a/seeds/staging.js +++ b/seeds/staging.js @@ -4,13 +4,6 @@ const { pipeline } = require("stream/promises"); const { Readable } = require("stream"); const { from: copyFrom } = require("pg-copy-streams"); -let logger; -try { - logger = require("../src/logger"); -} catch { - logger = require(`${__dirname}/../build/src/logger`); -} - const STAGING_DIR = path.join(__dirname, "staging"); /* @@ -77,7 +70,7 @@ exports.seed = async function seed(knex) { ); } - logger.info("Starting staging seed..."); + console.log("Starting staging seed..."); /* * Use a raw pg client for the entire operation so that COPY commands @@ -96,13 +89,13 @@ exports.seed = async function seed(knex) { await client.query( `TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE` ); - logger.info(`Truncated ${table}`); + console.log(`Truncated ${table}`); } /* Insert via COPY */ for (const entry of TABLES) { await copyInsert(client, entry); - logger.info(`Copied ${entry.file} into ${entry.table}`); + console.log(`Copied ${entry.file} into ${entry.table}`); } /* @@ -121,7 +114,7 @@ exports.seed = async function seed(knex) { ) `); } - logger.info("Advanced sequences past seeded IDs"); + console.log("Advanced sequences past seeded IDs"); await client.query("COMMIT"); } catch (err) { @@ -131,5 +124,5 @@ exports.seed = async function seed(knex) { await knex.client.releaseConnection(client); } - logger.info("Staging seed complete!"); + console.log("Staging seed complete!"); }; diff --git a/src/config.js b/src/config.js index c676c8cdb..be32fc662 100644 --- a/src/config.js +++ b/src/config.js @@ -762,6 +762,26 @@ const validators = { desc: "Custom base URL for Switchboard client.", default: undefined }), + TELNYX_API_KEY: str({ + desc: + "Telnyx API key (secret) used server-side to mint short-lived WebRTC access tokens. Never sent to the browser.", + default: undefined + }), + TELNYX_TELEPHONY_CREDENTIAL_ID: str({ + desc: + "ID of a Telnyx telephony credential (tied to a SIP connection) that WebRTC access tokens are minted from.", + default: undefined + }), + TELNYX_DEFAULT_FROM_NUMBER: str({ + desc: + "Caller ID number (E.164) used for dialer calls when not sourcing numbers from a messaging service (e.g. local/fakeservice testing). Must be a number owned by your Telnyx account.", + default: undefined + }), + DIALER_SHIFT_SIZE: num({ + desc: + "Number of contacts assigned to a volunteer per call shift when they request calls.", + default: 10 + }), VAN_BASE_URL: url({ desc: "The base url to use when interacting with VAN (may need to change for international use)", diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/hooks.ts b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/hooks.ts index da1c97d27..84469d3eb 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/hooks.ts +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/hooks.ts @@ -206,15 +206,18 @@ const stagedTextersReducer: StagedTexterReducer = (state, action) => { minNewContacts ); - editedTexter.assignment = { - ...editedTexter.assignment, - needsMessageCount: newContactCount - texterMessagedCount, - contactsCount: newContactCount + const updatedTexter = { + ...editedTexter, + assignment: { + ...editedTexter.assignment, + needsMessageCount: newContactCount - texterMessagedCount, + contactsCount: newContactCount + } }; const newUpsertedTexters = state.upsertedTexters .filter(({ id }) => id !== editedTexter.id) - .concat([editedTexter]); + .concat([updatedTexter]); return { ...state, diff --git a/src/containers/AdminCampaignList.jsx b/src/containers/AdminCampaignList.jsx index f2ffc0fdd..8c5ecf64d 100644 --- a/src/containers/AdminCampaignList.jsx +++ b/src/containers/AdminCampaignList.jsx @@ -6,7 +6,9 @@ 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 FormControlLabel from "@material-ui/core/FormControlLabel"; import Snackbar from "@material-ui/core/Snackbar"; +import Switch from "@material-ui/core/Switch"; import TextField from "@material-ui/core/TextField"; import Typography from "@material-ui/core/Typography"; import ClearIcon from "@material-ui/icons/Clear"; @@ -17,7 +19,6 @@ import AlertTitle from "@material-ui/lab/AlertTitle"; import SpeedDial from "@material-ui/lab/SpeedDial"; import SpeedDialAction from "@material-ui/lab/SpeedDialAction"; import SpeedDialIcon from "@material-ui/lab/SpeedDialIcon"; -import { Toggle } from "material-ui"; import PropTypes from "prop-types"; import React from "react"; import { withRouter } from "react-router-dom"; @@ -91,6 +92,9 @@ class AdminCampaignList extends React.Component { releasingAllReplies: false, releaseAllRepliesError: undefined, releaseAllRepliesResult: undefined, + releaseAgeInHours: "1", + releaseOnRestricted: false, + limitToTextableContacts: true, campaignDetailsForExport: [], showExportModal: false, showExportSnackbar: false, @@ -177,7 +181,12 @@ class AdminCampaignList extends React.Component { }; startReleasingAllReplies = () => { - this.setState({ releasingAllReplies: true }); + this.setState({ + releasingAllReplies: true, + releaseAgeInHours: "1", + releaseOnRestricted: false, + limitToTextableContacts: true + }); }; handleOnCreateClickFromTemplate = () => { @@ -211,10 +220,12 @@ class AdminCampaignList extends React.Component { }; releaseAllReplies = () => { - const ageInHours = parseFloat(this.numberOfHoursToReleaseRef.input.value); - const releaseOnRestricted = this.releaseOnRestrictedRef.state.switched; - const limitToCurrentlyTextableContacts = this - .limitToCurrentlyTextableContactsRef.state.switched; + const { + releaseAgeInHours, + releaseOnRestricted, + limitToTextableContacts: limitToCurrentlyTextableContacts + } = this.state; + const ageInHours = parseFloat(releaseAgeInHours); this.setState({ releasingInProgress: true }); @@ -326,22 +337,29 @@ class AdminCampaignList extends React.Component { to be unassigned? { - this.numberOfHoursToReleaseRef = el; - }} - defaultValue={1} + label="Number of Hours" + value={this.state.releaseAgeInHours} + onChange={(event) => + this.setState({ releaseAgeInHours: event.target.value }) + } />

Should we release replies on campaigns that are restricted to teams? If unchecked, replies on campaigns restricted to team members will stay assigned to their current texter. - { - this.releaseOnRestrictedRef = el; - }} - defaultToggled={false} + + this.setState({ + releaseOnRestricted: event.target.checked + }) + } + /> + } + label="Release on team-restricted campaigns" />

@@ -349,11 +367,18 @@ class AdminCampaignList extends React.Component { contact's timezone? If unchecked, replies will be released for contacts that may not be textable until later today or until tomorrow. - { - this.limitToCurrentlyTextableContactsRef = el; - }} - defaultToggled + + this.setState({ + limitToTextableContacts: event.target.checked + }) + } + /> + } + label="Only release contacts textable now" />
) : ( diff --git a/src/containers/AdminCampaignStats/components/TopLineStats.jsx b/src/containers/AdminCampaignStats/components/TopLineStats.jsx index 2ece2a463..032ef8f35 100644 --- a/src/containers/AdminCampaignStats/components/TopLineStats.jsx +++ b/src/containers/AdminCampaignStats/components/TopLineStats.jsx @@ -8,6 +8,7 @@ import CampaignStat from "./CampaignStat"; export const TopLineStats = (props) => { const { + campaignType, contactsCount, assignments, needsMessageCount, @@ -17,6 +18,8 @@ export const TopLineStats = (props) => { percentUnhandledReplies } = props; + const isCallCampaign = campaignType === "CALL"; + const highUnhandledReplyPercent = 25; const campaignPercent = percentUnhandledReplies.campaign?.stats.percentUnhandledReplies; @@ -34,7 +37,7 @@ export const TopLineStats = (props) => { { } /> + {!isCallCampaign && ( + + + + )} - - - { } /> - - - + {!isCallCampaign && ( + + + + )} { }; TopLineStats.propTypes = { - campaignId: PropTypes.string.isRequired + campaignId: PropTypes.string.isRequired, + campaignType: PropTypes.string }; const queries = { diff --git a/src/containers/AdminCampaignStats/index.jsx b/src/containers/AdminCampaignStats/index.jsx index 15d494d49..0d1e619a3 100644 --- a/src/containers/AdminCampaignStats/index.jsx +++ b/src/containers/AdminCampaignStats/index.jsx @@ -336,12 +336,14 @@ class AdminCampaignStats extends React.Component { > Edit - + {campaign.campaignType !== "CALL" && ( + + )} {isAdmin && ( <>
- +
-
- Outbound Deliverability -
- -
- -
- Texter stats -
- + {campaign.campaignType !== "CALL" && ( + <> +
+ Outbound Deliverability +
+ +
+ + )} + + {campaign.campaignType !== "CALL" && ( + <> +
+ Texter stats +
+ + + )} = (props) => { setMenuAnchor ]); + // Call campaigns track contacts in dialer_campaign_contact and have no + // replies/second-pass concept, so they get a trimmed, call-worded menu. + const isCallCampaign = campaign.campaignType === CampaignType.Call; + return (
@@ -50,33 +55,39 @@ export const CampaignListMenu: React.FC = (props) => { campaign })} > - Release Unsent Messages - - - Mark for a Second Pass - - - Release Unreplied Conversations + {isCallCampaign + ? "Release Uncalled Contacts" + : "Release Unsent Messages"} + {!isCallCampaign && ( + + Mark for a Second Pass + + )} + {!isCallCampaign && ( + + Release Unreplied Conversations + + )} {!campaign.isArchived && ( @@ -96,14 +107,18 @@ export const CampaignListMenu: React.FC = (props) => { - Delete Unmessaged Contacts + {isCallCampaign + ? "Delete Uncalled Contacts" + : "Delete Unmessaged Contacts"} - - Un-Mark for Second Pass - + {!isCallCampaign && ( + + Un-Mark for Second Pass + + )} = { releaseUnsentMessages: { - title: (campaign) => `Release Unsent Messages for ${campaign.title}`, - body: () => `Releasing unsent messages for this campaign will cause unsent messages in this campaign\ + title: (campaign) => + campaign.campaignType === CampaignType.Call + ? `Release Uncalled Contacts for ${campaign.title}` + : `Release Unsent Messages for ${campaign.title}`, + body: (campaign) => + campaign?.campaignType === CampaignType.Call + ? `Releasing uncalled contacts for this campaign will remove not-yet-called contacts from volunteers'\ + shifts. This means those volunteers will no longer have these contacts to call, but the contacts will become\ + available to assign again via the autoassignment functionality.` + : `Releasing unsent messages for this campaign will cause unsent messages in this campaign\ to be removed from texter's assignments. This means that these texters will no longer be able to send\ these messages, but these messages will become available to assign via the autoassignment\ functionality.`, @@ -55,8 +64,17 @@ export const dialogOperations: Record = { mutationName: "releaseMessages" }, deleteNeedsMessage: { - title: (campaign) => `Delete Un-Messaged Contacts for ${campaign.title}`, - body: () => `Deleting unmessaged contacts for this campaign will remove contacts that have not received a message yet.\ + title: (campaign) => + campaign.campaignType === CampaignType.Call + ? `Delete Uncalled Contacts for ${campaign.title}` + : `Delete Un-Messaged Contacts for ${campaign.title}`, + body: (campaign) => + campaign?.campaignType === CampaignType.Call + ? `Deleting uncalled contacts for this campaign will remove contacts that have not been called yet.\ + This operation is useful if, for one reason or another, you don't want to call any more contacts on this\ + campaign. This might be because there's a mistake in the script or file, or because the event for which you\ + were calling these contacts has already happened.` + : `Deleting unmessaged contacts for this campaign will remove contacts that have not received a message yet.\ This operation is useful if, for one reason or another, you don't want to message any more contacts on this\ campaign, but still want to use autoassignment to handle replies. This might be because there's a mistake in\ the script or file, or because the event for which you were sending these messages has already happened.`, diff --git a/src/containers/TexterTodoList/components/AssignmentSummary.tsx b/src/containers/TexterTodoList/components/AssignmentSummary.tsx index 2d61611d2..6e4070f17 100644 --- a/src/containers/TexterTodoList/components/AssignmentSummary.tsx +++ b/src/containers/TexterTodoList/components/AssignmentSummary.tsx @@ -1,9 +1,10 @@ import { makeStyles } from "@material-ui/core"; +import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import CardActions from "@material-ui/core/CardActions"; import CardHeader from "@material-ui/core/CardHeader"; import Divider from "@material-ui/core/Divider"; -import type { Assignment } from "@spoke/spoke-codegen"; +import type { Assignment, CampaignType } from "@spoke/spoke-codegen"; import React from "react"; import { useHistory } from "react-router-dom"; @@ -39,6 +40,7 @@ interface Props { totalMessagedCount: number; pastMessagesCount: number; skippedMessagesCount: number; + campaignType?: CampaignType; } export const AssignmentSummary: React.FC = (props) => { @@ -104,10 +106,13 @@ export const AssignmentSummary: React.FC = (props) => { const { title, description, + campaignType, primaryColor = context.theme?.defaultCampaignColor, logoImageUrl = context.theme?.defaultCampaignLogo, introHtml - } = assignment.campaign; + } = assignment.campaign as any; + + const isCallCampaign = campaignType === "CALL"; const subtitle = `${description}`; @@ -131,57 +136,73 @@ export const AssignmentSummary: React.FC = (props) => {
- {renderBadgedButton({ - dataTestText: "sendFirstTexts", - assignment, - title: "Send first texts", - type: "initial", - count: unmessagedCount, - primary: true, - contactsFilter: "text", - hideIfZero: true - })} - {renderBadgedButton({ - dataTestText: "sendReplies", - assignment, - title: "Send replies", - type: "reply", - count: unrepliedCount, - primary: false, - disabled: false, - contactsFilter: "reply", - hideIfZero: true - })} - {renderBadgedButton({ - assignment, - title: "Past Messages", - type: "past", - count: pastMessagesCount, - primary: false, - disabled: false, - contactsFilter: "stale", - hideIfZero: true - })} - {renderBadgedButton({ - assignment, - title: "Skipped Messages", - type: "past", - count: skippedMessagesCount, - primary: false, - disabled: false, - contactsFilter: "skipped", - hideIfZero: true - })} - {renderBadgedButton({ - assignment, - title: "Send later", - type: "initial", - count: badTimezoneCount, - primary: false, - disabled: true, - contactsFilter: null, - hideIfZero: true - })} + {isCallCampaign ? ( + + ) : ( + <> + {renderBadgedButton({ + dataTestText: "sendFirstTexts", + assignment, + title: "Send first texts", + type: "initial", + count: unmessagedCount, + primary: true, + contactsFilter: "text", + hideIfZero: true + })} + {renderBadgedButton({ + dataTestText: "sendReplies", + assignment, + title: "Send replies", + type: "reply", + count: unrepliedCount, + primary: false, + disabled: false, + contactsFilter: "reply", + hideIfZero: true + })} + {renderBadgedButton({ + assignment, + title: "Past Messages", + type: "past", + count: pastMessagesCount, + primary: false, + disabled: false, + contactsFilter: "stale", + hideIfZero: true + })} + {renderBadgedButton({ + assignment, + title: "Skipped Messages", + type: "past", + count: skippedMessagesCount, + primary: false, + disabled: false, + contactsFilter: "skipped", + hideIfZero: true + })} + {renderBadgedButton({ + assignment, + title: "Send later", + type: "initial", + count: badTimezoneCount, + primary: false, + disabled: true, + contactsFilter: null, + hideIfZero: true + })} + + )}
diff --git a/src/containers/TexterTodoList/components/CallRequest.tsx b/src/containers/TexterTodoList/components/CallRequest.tsx new file mode 100644 index 000000000..5e1f3d0ca --- /dev/null +++ b/src/containers/TexterTodoList/components/CallRequest.tsx @@ -0,0 +1,87 @@ +import Button from "@material-ui/core/Button"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import PhoneIcon from "@material-ui/icons/Phone"; +import { + useCallShiftAvailableQuery, + useRequestCallShiftMutation +} from "@spoke/spoke-codegen"; +import React, { useState } from "react"; + +interface CallRequestProps { + organizationId: string; +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: theme.spacing(1), + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2) + }, + message: { + color: theme.palette.text.secondary + } +})); + +const CallRequest: React.FC = ({ organizationId }) => { + const classes = useStyles(); + const [message, setMessage] = useState(null); + + const { data, loading } = useCallShiftAvailableQuery({ + variables: { organizationId }, + fetchPolicy: "network-only" + }); + + const [ + requestCallShift, + { loading: requesting } + ] = useRequestCallShiftMutation({ + // Refresh both the todo list (so the new shift's "Start Calling" appears) + // and our own availability. + refetchQueries: ["getTodos", "CallShiftAvailable"] + }); + + const handleRequest = async () => { + setMessage(null); + try { + const response = await requestCallShift({ + variables: { organizationId } + }); + const count = response.data?.requestCallShift.count ?? 0; + setMessage( + count > 0 + ? `Assigned ${count} ${count === 1 ? "call" : "calls"} to your shift.` + : "No calls are available right now." + ); + } catch (err) { + setMessage((err as Error).message); + } + }; + + // Hide entirely when there's nothing to request (mirrors TexterRequest). + if (loading || !data?.callShiftAvailable) return null; + + return ( +
+ + {message && ( + + {message} + + )} +
+ ); +}; + +export default CallRequest; diff --git a/src/containers/TexterTodoList/index.jsx b/src/containers/TexterTodoList/index.jsx index b7538af2e..33ba672a2 100644 --- a/src/containers/TexterTodoList/index.jsx +++ b/src/containers/TexterTodoList/index.jsx @@ -9,6 +9,7 @@ import { compose } from "recompose"; import Empty from "../../components/Empty"; import { loadData } from "../hoc/with-operations"; import AssignmentSummary from "./components/AssignmentSummary"; +import CallRequest from "./components/CallRequest"; import TexterRequest from "./components/TexterRequest"; class TexterTodoList extends React.Component { @@ -52,7 +53,9 @@ class TexterTodoList extends React.Component { .slice() .sort() .map((assignment) => { + const isCallCampaign = assignment.campaign.campaignType === "CALL"; if ( + isCallCampaign || assignment.unmessagedCount > 0 || assignment.unrepliedCount > 0 || assignment.badTimezoneCount > 0 || @@ -104,6 +107,7 @@ class TexterTodoList extends React.Component { organizationId={this.props.match.params.organizationId} />
+ {renderedTodos.length === 0 ? empty : renderedTodos}
; +type InteractionStep = DialerContact["interactionSteps"][0]; + +interface DialerContactProps { + contact: DialerContact; + assignmentId: string; + organizationId: string; + onNextContact: () => void; +} + +const useStyles = makeStyles((theme) => ({ + root: { + maxWidth: 720, + margin: "0 auto", + padding: theme.spacing(3), + // Fill the full width when the dialer layout stacks on mobile. + [theme.breakpoints.down("sm")]: { + maxWidth: "none" + } + }, + header: { + marginBottom: theme.spacing(2) + }, + name: { + fontWeight: 700 + }, + tags: { + display: "flex", + flexWrap: "wrap", + gap: theme.spacing(0.5), + alignItems: "center", + marginTop: theme.spacing(1) + }, + tagChip: { + fontWeight: 600 + }, + section: { + marginBottom: theme.spacing(3) + }, + transcript: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1) + }, + row: { + display: "flex" + }, + rowLeft: { + justifyContent: "flex-start" + }, + rowRight: { + justifyContent: "flex-end" + }, + bubble: { + maxWidth: "78%", + padding: theme.spacing(1.25, 1.75), + borderRadius: 16, + whiteSpace: "pre-wrap", + textAlign: "left" + }, + scriptBubble: { + backgroundColor: theme.palette.grey[100], + borderTopLeftRadius: 4 + }, + answerBubble: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + borderTopRightRadius: 4, + cursor: "pointer", + transition: "opacity 0.15s", + "&:hover": { + opacity: 0.85 + } + }, + question: { + fontWeight: 600, + marginBottom: theme.spacing(1) + }, + answers: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + alignItems: "flex-end", + marginTop: theme.spacing(2) + }, + answerButton: { + textTransform: "none" + }, + endNote: { + fontStyle: "italic", + color: theme.palette.text.secondary, + marginTop: theme.spacing(2) + } +})); + +const normalizeParentId = ( + parentInteractionId: string | null | undefined +): string | null => + !parentInteractionId || + parentInteractionId === "" || + parentInteractionId === "0" + ? null + : parentInteractionId; + +const pickScript = (scriptOptions: Array): string => + sample(scriptOptions.filter((s): s is string => !!s)) ?? ""; + +// Suggest a disposition from the Telnyx call outcome. If the call was answered +// we can't tell a live person from voicemail, so leave it to the volunteer. +const deriveDisposition = ( + wasAnswered: boolean, + cause: string | null +): Disposition | undefined => { + if (wasAnswered) return undefined; + if (cause === "USER_BUSY") return "busy"; + // NO_ANSWER, NO_USER_RESPONSE, ORIGINATOR_CANCEL, CALL_REJECTED, + // UNALLOCATED_NUMBER, timeouts, etc. all read as "nobody to talk to". + return "no_answer"; +}; + +// Map the Telnyx outcome to a dialer_call status (the call's result code, +// distinct from the human-chosen disposition). +const deriveCallStatus = ( + wasAnswered: boolean, + cause: string | null +): string => { + if (wasAnswered) return "COMPLETED"; + const errorCauses = [ + "UNALLOCATED_NUMBER", + "INVALID_NUMBER_FORMAT", + "NO_ROUTE_DESTINATION", + "INCOMPATIBLE_DESTINATION" + ]; + if (cause && errorCauses.includes(cause)) return "ERROR"; + return "NO_ANSWER"; +}; + +// Reconcile the persisted status with the volunteer's final disposition. +const dispositionToStatus: Record = { + answered: "COMPLETED", + no_answer: "NO_ANSWER", + voicemail: "VOICEMAIL", + busy: "NO_ANSWER", + do_not_call: "COMPLETED" +}; + +const DialerContact: React.FC = ({ + contact, + assignmentId, + organizationId, + onNextContact +}) => { + const classes = useStyles(); + + const { + clientReady, + callState, + isMuted, + callWasAnswered, + callEndCause, + callStartedAt, + callEndedAt, + dial, + hangup, + toggleMute, + error: webRTCError + } = useTelnyxWebRTC(); + + const [dialerCallId, setDialerCallId] = useState(null); + const [pendingRewindIndex, setPendingRewindIndex] = useState( + null + ); + const [showDisposition, setShowDisposition] = useState(false); + const [isTagDialogOpen, setIsTagDialogOpen] = useState(false); + // Canned responses the volunteer has pulled into the script this call. Reset + // automatically per contact (DialerContact is keyed by contact id). + const [insertedResponses, setInsertedResponses] = useState< + Array<{ id: number; text: string }> + >([]); + const insertedResponseIdRef = useRef(0); + + // Index the interaction-step tree once per contact. + const { stepById, childrenByParent, rootStep } = useMemo(() => { + const byId = new Map(); + const childrenOf = new Map(); + const liveSteps = contact.interactionSteps.filter((s) => !s.isDeleted); + + liveSteps.forEach((step) => byId.set(step.id, step)); + liveSteps.forEach((step) => { + const parentId = normalizeParentId(step.parentInteractionId); + if (parentId) { + const siblings = childrenOf.get(parentId) ?? []; + siblings.push(step); + childrenOf.set(parentId, siblings); + } + }); + + const root = liveSteps.find( + (step) => normalizeParentId(step.parentInteractionId) === null + ); + return { stepById: byId, childrenByParent: childrenOf, rootStep: root }; + }, [contact.interactionSteps]); + + const childrenOf = useCallback( + (stepId: string): InteractionStep[] => childrenByParent.get(stepId) ?? [], + [childrenByParent] + ); + + // Pick one script variant per step, stable across re-renders. + const scriptByStep = useMemo(() => { + const map: Record = {}; + contact.interactionSteps.forEach((step) => { + map[step.id] = pickScript(step.scriptOptions ?? []); + }); + return map; + }, [contact.interactionSteps]); + + // Current user is the "texter" for {texterFirstName}/{texterLastName} tokens. + const { data: profileData } = useGetCurrentUserProfileQuery(); + + // Interpolate script tokens using the same engine as the texting view, so + // contact fields, {texterFirstName}/{texterLastName}, campaign variables, and + // custom fields all resolve consistently. + const interpolate = useMemo(() => { + const customFieldsJson = contact.customFields ?? "{}"; + const scriptContact = { + firstName: contact.firstName ?? "", + lastName: contact.lastName ?? "", + cell: "", + zip: contact.zip ?? "", + customFields: customFieldsJson + }; + const customFields = customFieldsJsonStringToArray(customFieldsJson); + const campaignVariables = contact.campaignVariables ?? []; + const texter = { + firstName: profileData?.currentUser?.firstName ?? "", + lastName: profileData?.currentUser?.lastName ?? "" + }; + return (script: string) => + applyScript({ + script, + contact: scriptContact, + customFields, + campaignVariables, + texter + }); + }, [ + contact.customFields, + contact.firstName, + contact.lastName, + contact.zip, + contact.campaignVariables, + profileData + ]); + + // responses: interactionStepId -> chosen answer value (for the question on + // that step). Seed from any previously saved responses. + const initialResponses = useMemo(() => { + const seeded: Record = {}; + (contact.questionResponseValues ?? []).forEach((qr) => { + seeded[qr.interactionStepId] = qr.value; + }); + return seeded; + }, [contact.questionResponseValues]); + + const [responses, setResponses] = useState>( + initialResponses + ); + + // path: step ids from root to the current step. Reconstruct how far the + // saved responses get us so a re-dial resumes where it left off. + const [path, setPath] = useState(() => { + if (!rootStep) return []; + const walked = [rootStep.id]; + let current: InteractionStep | undefined = rootStep; + while (current && initialResponses[current.id]) { + const step: InteractionStep = current; + const chosen: InteractionStep | undefined = childrenOf(step.id).find( + (child) => child.answerOption === initialResponses[step.id] + ); + if (!chosen) break; + walked.push(chosen.id); + current = chosen; + } + return walked; + }); + + const [ + initiateCall, + { loading: initiating, error: initiateError } + ] = useInitiateCallMutation(); + const [updateDialerCall] = useUpdateDialerCallMutation(); + const [saveQuestionResponses] = useSaveDialerQuestionResponsesMutation(); + const [ + markComplete, + { loading: completing } + ] = useMarkDialerContactCompleteMutation(); + const [tagContact, { loading: tagging }] = useTagDialerContactMutation(); + + // Record the real telephony result + timing on the dialer_call exactly once + // when the call ends, whether the volunteer hung up or the call ended on its + // own (no answer, busy, remote hangup). + const endRecordedRef = useRef(false); + useEffect(() => { + if (callState === "ended" && dialerCallId && !endRecordedRef.current) { + endRecordedRef.current = true; + setShowDisposition(true); + updateDialerCall({ + variables: { + dialerCallId, + input: { + status: deriveCallStatus(callWasAnswered, callEndCause), + answeredAt: callStartedAt + ? new Date(callStartedAt).toISOString() + : null, + endedAt: callEndedAt ? new Date(callEndedAt).toISOString() : null + } + } + }); + } + }, [ + callState, + dialerCallId, + callWasAnswered, + callEndCause, + callStartedAt, + callEndedAt, + updateDialerCall + ]); + + const handleDial = useCallback(async () => { + try { + const { data } = await initiateCall({ + variables: { assignmentId, dialerCampaignContactId: contact.id } + }); + if (!data?.initiateCall) return; + const { + dialerCallId: callId, + contactPhone, + fromNumber + } = data.initiateCall; + setDialerCallId(String(callId)); + dial(contactPhone, fromNumber); + } catch (_err) { + // error surfaced via mutation result + } + }, [assignmentId, contact.id, dial, initiateCall]); + + // Just end the call; the call-end effect records the real outcome + timing + // and surfaces the disposition form. + const handleHangup = useCallback(() => { + hangup(); + }, [hangup]); + + // Record the answer for the current step and advance to the chosen child. + const handleSelectAnswer = useCallback( + (stepId: string, answer: string, childId: string) => { + setResponses((prev) => ({ ...prev, [stepId]: answer })); + setPath((prev) => [...prev, childId]); + }, + [] + ); + + // Drop a canned response into the script as a bubble for the volunteer to + // read aloud. Stored raw and interpolated at render, like the script bubbles. + const handleInsertCannedResponse = useCallback((text: string) => { + insertedResponseIdRef.current += 1; + setInsertedResponses((prev) => [ + ...prev, + { id: insertedResponseIdRef.current, text } + ]); + }, []); + + // Click an inserted canned response to undo it (mirrors clicking an answer + // bubble to revise it). Removal is trivially reversible — just re-pick it — + // so it skips the confirm dialog the answer rewind uses. + const handleRemoveCannedResponse = useCallback((id: number) => { + setInsertedResponses((prev) => prev.filter((r) => r.id !== id)); + }, []); + + // Apply the tag changes from the dialog. The mutation returns the contact's + // updated tag set, which Apollo merges into the cache so the chips refresh. + const handleApplyTags = useCallback( + async (addedTagIds: string[], removedTagIds: string[]) => { + if (addedTagIds.length > 0 || removedTagIds.length > 0) { + await tagContact({ + variables: { + dialerCampaignContactId: contact.id, + tag: { addedTagIds, removedTagIds } + } + }); + } + setIsTagDialogOpen(false); + }, + [contact.id, tagContact] + ); + + // Truncate the path back to `index` and clear answers for everything from + // there onward so they don't get saved as stale responses. + const handleJumpTo = useCallback( + (index: number) => { + if (index < 0 || index >= path.length - 1) return; + const discarded = path.slice(index); + setResponses((prev) => { + const next = { ...prev }; + discarded.forEach((id) => delete next[id]); + return next; + }); + setPath(path.slice(0, index + 1)); + }, + [path] + ); + + const confirmRewind = useCallback(() => { + if (pendingRewindIndex !== null) { + handleJumpTo(pendingRewindIndex); + } + setPendingRewindIndex(null); + }, [handleJumpTo, pendingRewindIndex]); + + const handleDispositionSubmit = useCallback( + async (disposition: Disposition) => { + const questionResponses = Object.entries( + responses + ).map(([interactionStepId, value]) => ({ interactionStepId, value })); + + if (questionResponses.length > 0) { + await saveQuestionResponses({ + variables: { + dialerCampaignContactId: contact.id, + questionResponses + } + }); + } + + await markComplete({ + variables: { + dialerCampaignContactId: contact.id, + callStatus: disposition + } + }); + + if (dialerCallId) { + await updateDialerCall({ + variables: { + dialerCallId, + input: { + status: dispositionToStatus[disposition] + } + } + }); + } + + onNextContact(); + }, + [ + contact.id, + dialerCallId, + markComplete, + onNextContact, + responses, + saveQuestionResponses, + updateDialerCall + ] + ); + + const currentStepId = path[path.length - 1]; + const currentStep = currentStepId ? stepById.get(currentStepId) : undefined; + const currentAnswers = currentStepId ? childrenOf(currentStepId) : []; + const currentQuestion = + currentStep?.questionText || currentStep?.question?.text || ""; + + return ( + +
+ + {contact.firstName} + + {contact.zip && ( + + ZIP: {contact.zip} + + )} +
+ {contact.tags.map((tag) => ( + + ))} + +
+
+ + + + {(webRTCError || initiateError) && ( + + {webRTCError ?? initiateError?.message} + + )} + +
+ + {!showDisposition && ( + + )} +
+ + {(currentStep || insertedResponses.length > 0) && ( +
+
+ {path.map((stepId, index) => { + const script = interpolate(scriptByStep[stepId] ?? ""); + const answer = responses[stepId]; + return ( + + {script && ( +
+
+ {script} +
+
+ )} + {answer !== undefined && ( +
+ setPendingRewindIndex(index)} + > + {answer} + +
+ )} +
+ ); + })} + {insertedResponses.map((inserted) => ( +
+ handleRemoveCannedResponse(inserted.id)} + > + + {interpolate(inserted.text)} + + +
+ ))} +
+ + {currentStep && + (currentAnswers.length > 0 ? ( + <> + {currentQuestion && ( + + {currentQuestion} + + )} +
+ {currentAnswers.map((answer) => ( + + ))} +
+ + ) : ( + + End of script. + + ))} +
+ )} + +
+ +
+ + setPendingRewindIndex(null)} + > + Change this answer? + + + This will clear your answers from this question onward. + + + + + + + + + {showDisposition && ( + + )} + + setIsTagDialogOpen(false)} + onApply={handleApplyTags} + /> +
+ ); +}; + +export default DialerContact; diff --git a/src/containers/VolunteerDialer/components/CallControls.tsx b/src/containers/VolunteerDialer/components/CallControls.tsx new file mode 100644 index 000000000..14f488be3 --- /dev/null +++ b/src/containers/VolunteerDialer/components/CallControls.tsx @@ -0,0 +1,107 @@ +import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { makeStyles } from "@material-ui/core/styles"; +import Tooltip from "@material-ui/core/Tooltip"; +import CallIcon from "@material-ui/icons/Call"; +import CallEndIcon from "@material-ui/icons/CallEnd"; +import MicIcon from "@material-ui/icons/Mic"; +import MicOffIcon from "@material-ui/icons/MicOff"; +import React from "react"; + +import type { CallState } from "../useTelnyxWebRTC"; + +interface CallControlsProps { + callState: CallState; + clientReady: boolean; + isMuted: boolean; + isSubmitting: boolean; + onDial: () => void; + onHangup: () => void; + onToggleMute: () => void; +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + gap: theme.spacing(2), + alignItems: "center", + marginBottom: theme.spacing(2) + }, + dialButton: { + backgroundColor: theme.palette.success?.main ?? "#4caf50", + color: "#fff", + "&:hover": { + backgroundColor: theme.palette.success?.dark ?? "#388e3c" + } + }, + hangupButton: { + backgroundColor: theme.palette.error.main, + color: "#fff", + "&:hover": { + backgroundColor: theme.palette.error.dark + } + } +})); + +const CallControls: React.FC = ({ + callState, + clientReady, + isMuted, + isSubmitting, + onDial, + onHangup, + onToggleMute +}) => { + const classes = useStyles(); + const isInCall = + callState === "dialing" || + callState === "ringing" || + callState === "active" || + callState === "held"; + const canDial = clientReady && callState === "ready" && !isSubmitting; + + return ( +
+ {!isInCall ? ( + + ) : ( + <> + + + + + + )} +
+ ); +}; + +export default CallControls; diff --git a/src/containers/VolunteerDialer/components/CallStatusBar.tsx b/src/containers/VolunteerDialer/components/CallStatusBar.tsx new file mode 100644 index 000000000..eeca81874 --- /dev/null +++ b/src/containers/VolunteerDialer/components/CallStatusBar.tsx @@ -0,0 +1,76 @@ +import Chip from "@material-ui/core/Chip"; +import { makeStyles } from "@material-ui/core/styles"; +import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord"; +import React from "react"; + +import type { CallState } from "../useTelnyxWebRTC"; +import CallTimer from "./CallTimer"; + +interface CallStatusBarProps { + callState: CallState; + callStartedAt?: number | null; + callEndedAt?: number | null; +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + alignItems: "center", + marginBottom: theme.spacing(2) + }, + chip: { + fontWeight: 600, + fontSize: "0.85rem" + } +})); + +const STATE_LABELS: Record = { + idle: "Ready to dial", + connecting: "Connecting…", + ready: "Ready to dial", + dialing: "Dialing…", + ringing: "Ringing…", + active: "On call", + held: "On hold", + ended: "Call ended", + error: "Connection error" +}; + +const STATE_COLORS: Record = { + idle: "default", + connecting: "default", + ready: "default", + dialing: "primary", + ringing: "primary", + active: "secondary", + held: "default", + ended: "default", + error: "secondary" +}; + +const CallStatusBar: React.FC = ({ + callState, + callStartedAt = null, + callEndedAt = null +}) => { + const classes = useStyles(); + const isLive = + callState === "active" || + callState === "ringing" || + callState === "dialing"; + + return ( +
+ : undefined} + label={STATE_LABELS[callState]} + variant="outlined" + /> + +
+ ); +}; + +export default CallStatusBar; diff --git a/src/containers/VolunteerDialer/components/CallTimer.tsx b/src/containers/VolunteerDialer/components/CallTimer.tsx new file mode 100644 index 000000000..d9742b71b --- /dev/null +++ b/src/containers/VolunteerDialer/components/CallTimer.tsx @@ -0,0 +1,52 @@ +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import React, { useEffect, useState } from "react"; + +interface CallTimerProps { + // Epoch ms when the call was answered, or null if it never connected. + startedAt: number | null; + // Epoch ms when the call ended, or null while still in progress. + endedAt: number | null; +} + +const useStyles = makeStyles((theme) => ({ + timer: { + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + marginLeft: theme.spacing(1.5), + color: theme.palette.text.secondary + } +})); + +const formatDuration = (totalSeconds: number): string => { + const safe = Math.max(0, totalSeconds); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +}; + +const CallTimer: React.FC = ({ startedAt, endedAt }) => { + const classes = useStyles(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + // Only tick while a call is in progress. + if (startedAt === null || endedAt !== null) return undefined; + setNow(Date.now()); + const intervalId = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(intervalId); + }, [startedAt, endedAt]); + + // No duration to show until the call actually connects. + if (startedAt === null) return null; + + const elapsedSeconds = Math.floor(((endedAt ?? now) - startedAt) / 1000); + + return ( + + {formatDuration(elapsedSeconds)} + + ); +}; + +export default CallTimer; diff --git a/src/containers/VolunteerDialer/components/CannedResponses.tsx b/src/containers/VolunteerDialer/components/CannedResponses.tsx new file mode 100644 index 000000000..053bab0bd --- /dev/null +++ b/src/containers/VolunteerDialer/components/CannedResponses.tsx @@ -0,0 +1,130 @@ +import ButtonBase from "@material-ui/core/ButtonBase"; +import Collapse from "@material-ui/core/Collapse"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; +import { useGetAssignmentCannedResponsesQuery } from "@spoke/spoke-codegen"; +import React, { useState } from "react"; + +interface CannedResponsesProps { + assignmentId: string; + // Same {field} interpolation the script bubbles use, so the preview reads + // naturally with the contact's details filled in. + interpolate: (text: string) => string; + // Called with the raw response text when a volunteer picks one; the caller + // drops it into the call script. + onSelect: (text: string) => void; +} + +const useStyles = makeStyles((theme) => ({ + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + padding: theme.spacing(1, 0), + textAlign: "left" + }, + expandIcon: { + transition: theme.transitions.create("transform") + }, + expandIconOpen: { + transform: "rotate(180deg)" + }, + list: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1.5), + marginTop: theme.spacing(1) + }, + item: { + display: "block", + width: "100%", + textAlign: "left", + padding: theme.spacing(1.25, 1.5), + borderRadius: 8, + backgroundColor: theme.palette.grey[50], + border: `1px solid ${theme.palette.divider}`, + transition: "background-color 0.15s", + "&:hover": { + backgroundColor: theme.palette.action.hover + } + }, + title: { + fontWeight: 600 + }, + text: { + whiteSpace: "pre-wrap" + } +})); + +// Reference talking points for the volunteer to read aloud during a call. +// Picking one appends it to the call script (the texting view inserts it into +// the message box instead — a call has nothing to send). +const CannedResponses: React.FC = ({ + assignmentId, + interpolate, + onSelect +}) => { + const classes = useStyles(); + const [open, setOpen] = useState(false); + + const { data, loading, error } = useGetAssignmentCannedResponsesQuery({ + variables: { assignmentId } + }); + const cannedResponses = data?.assignment?.cannedResponses ?? []; + + // No canned responses for this campaign: render nothing so the call view + // stays uncluttered (mirrors the texting view hiding the button). + if (!loading && !error && cannedResponses.length === 0) return null; + + return ( + <> + setOpen(!open)}> + + Canned Responses + {cannedResponses.length > 0 ? ` (${cannedResponses.length})` : ""} + + + + + {loading && ( + + Loading… + + )} + {error && ( + + Failed to load canned responses. + + )} +
+ {cannedResponses.map((response) => ( + onSelect(response.text)} + > + + {response.title} + + + {interpolate(response.text)} + + + ))} +
+
+ + ); +}; + +export default CannedResponses; diff --git a/src/containers/VolunteerDialer/components/ContactHistoryPanel.tsx b/src/containers/VolunteerDialer/components/ContactHistoryPanel.tsx new file mode 100644 index 000000000..6d939e499 --- /dev/null +++ b/src/containers/VolunteerDialer/components/ContactHistoryPanel.tsx @@ -0,0 +1,161 @@ +import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import Divider from "@material-ui/core/Divider"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import { useDialerContactTextingHistoryLazyQuery } from "@spoke/spoke-codegen"; +import React from "react"; + +import { DateTime } from "../../../lib/datetime"; + +interface ContactHistoryPanelProps { + dialerCampaignContactId: string; +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + // Allow the flex column to shrink so content never forces horizontal scroll. + minWidth: 0 + }, + intro: { + color: theme.palette.text.secondary + }, + conversation: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5) + }, + campaignTitle: { + fontWeight: 600 + }, + thread: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.75), + marginTop: theme.spacing(1) + }, + row: { + display: "flex", + minWidth: 0 + }, + rowLeft: { + justifyContent: "flex-start" + }, + rowRight: { + justifyContent: "flex-end" + }, + bubble: { + maxWidth: "85%", + padding: theme.spacing(0.75, 1.25), + borderRadius: 12, + whiteSpace: "pre-wrap", + // Break long words/URLs so a message can't push the panel wider. + overflowWrap: "anywhere" + }, + receivedBubble: { + backgroundColor: theme.palette.grey[200], + borderTopLeftRadius: 4 + }, + sentBubble: { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + borderTopRightRadius: 4 + }, + time: { + display: "block", + marginTop: theme.spacing(0.25), + opacity: 0.7 + } +})); + +// On-demand panel showing the contact's prior texting conversations (same phone, +// same org) so a volunteer has context before calling. Lazily loaded — nothing +// is fetched until the volunteer asks for it. +const ContactHistoryPanel: React.FC = ({ + dialerCampaignContactId +}) => { + const classes = useStyles(); + + const [ + loadHistory, + { data, loading, called, error } + ] = useDialerContactTextingHistoryLazyQuery({ + variables: { dialerCampaignContactId } + }); + + const conversations = data?.dialerContactTextingHistory ?? []; + + return ( +
+ Texting history + + {!called && ( + <> + + See this contact's previous text conversations before you call. + + + + )} + + {loading && } + + {error && ( + + Failed to load texting history. + + )} + + {called && !loading && !error && conversations.length === 0 && ( + + No previous texting conversations with this contact. + + )} + + {conversations.map((conversation) => ( +
+ + {conversation.campaignTitle} + + +
+ {conversation.messages.map((message) => ( +
+
+ {message.text} + {message.createdAt && ( + + {DateTime.fromISO(message.createdAt).toRelative()} + + )} +
+
+ ))} +
+
+ ))} +
+ ); +}; + +export default ContactHistoryPanel; diff --git a/src/containers/VolunteerDialer/components/DispositionForm.tsx b/src/containers/VolunteerDialer/components/DispositionForm.tsx new file mode 100644 index 000000000..0e55cdafb --- /dev/null +++ b/src/containers/VolunteerDialer/components/DispositionForm.tsx @@ -0,0 +1,100 @@ +import Button from "@material-ui/core/Button"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import Select from "@material-ui/core/Select"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import React, { useState } from "react"; + +export type Disposition = + | "answered" + | "no_answer" + | "voicemail" + | "busy" + | "do_not_call"; + +interface DispositionFormProps { + onSubmit: (disposition: Disposition) => void; + isSubmitting: boolean; + // Pre-selected disposition, auto-derived from the call outcome. The volunteer + // can still change it before saving. + initialDisposition?: Disposition; +} + +const DISPOSITIONS: { value: Disposition; label: string }[] = [ + { value: "answered", label: "Answered" }, + { value: "no_answer", label: "No Answer" }, + { value: "voicemail", label: "Left Voicemail" }, + { value: "busy", label: "Busy" }, + { value: "do_not_call", label: "Do Not Call" } +]; + +const useStyles = makeStyles((theme) => ({ + root: { + marginTop: theme.spacing(3), + padding: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius + }, + title: { + marginBottom: theme.spacing(2), + fontWeight: 600 + }, + formControl: { + minWidth: 220, + marginBottom: theme.spacing(2) + }, + submitButton: { + display: "block" + } +})); + +const DispositionForm: React.FC = ({ + onSubmit, + isSubmitting, + initialDisposition = "answered" +}) => { + const classes = useStyles(); + const [disposition, setDisposition] = useState( + initialDisposition + ); + + const handleSubmit = () => { + onSubmit(disposition); + }; + + return ( +
+ + Call Outcome + + + Disposition + + + +
+ ); +}; + +export default DispositionForm; diff --git a/src/containers/VolunteerDialer/components/TagDialog.tsx b/src/containers/VolunteerDialer/components/TagDialog.tsx new file mode 100644 index 000000000..3a5af84cc --- /dev/null +++ b/src/containers/VolunteerDialer/components/TagDialog.tsx @@ -0,0 +1,80 @@ +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 DialogTitle from "@material-ui/core/DialogTitle"; +import type { TagInfoFragment } from "@spoke/spoke-codegen"; +import { useGetOrganizationTagsQuery } from "@spoke/spoke-codegen"; +import React, { useEffect, useMemo, useState } from "react"; + +import TagSelector from "../../../components/TagSelector"; + +interface TagDialogProps { + open: boolean; + organizationId: string; + // The contact's currently-applied tags (only ids are needed for the diff). + appliedTags: Array<{ id: string }>; + isSubmitting: boolean; + onClose: () => void; + onApply: (addedTagIds: string[], removedTagIds: string[]) => void; +} + +const TagDialog: React.FC = ({ + open, + organizationId, + appliedTags, + isSubmitting, + onClose, + onApply +}) => { + const { data } = useGetOrganizationTagsQuery({ + variables: { organizationId } + }); + const orgTags = useMemo(() => data?.organization?.tagList ?? [], [data]); + + const appliedTagIds = useMemo(() => new Set(appliedTags.map((t) => t.id)), [ + appliedTags + ]); + + const [selected, setSelected] = useState([]); + + // Seed the selection from the contact's current tags whenever the dialog + // opens (or the tag list finishes loading). + useEffect(() => { + if (open) { + setSelected(orgTags.filter((tag) => appliedTagIds.has(tag.id))); + } + }, [open, orgTags, appliedTagIds]); + + const handleSave = () => { + const selectedIds = new Set(selected.map((tag) => tag.id)); + const addedTagIds = selected + .filter((tag) => !appliedTagIds.has(tag.id)) + .map((tag) => tag.id); + const removedTagIds = [...appliedTagIds].filter( + (id) => !selectedIds.has(id) + ); + onApply(addedTagIds, removedTagIds); + }; + + return ( + + Manage Tags + + + + + + + + + ); +}; + +export default TagDialog; diff --git a/src/containers/VolunteerDialer/index.tsx b/src/containers/VolunteerDialer/index.tsx new file mode 100644 index 000000000..82c1a9952 --- /dev/null +++ b/src/containers/VolunteerDialer/index.tsx @@ -0,0 +1,203 @@ +import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import { makeStyles } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import ArrowBackIcon from "@material-ui/icons/ArrowBack"; +import { useGetNextDialerContactQuery } from "@spoke/spoke-codegen"; +import React, { useCallback, useRef, useState } from "react"; +import { useHistory, useParams } from "react-router-dom"; + +import ContactHistoryPanel from "./components/ContactHistoryPanel"; +import DialerContact from "./DialerContact"; + +const useStyles = makeStyles((theme) => ({ + root: { + // The TexterDashboard wrapper renders this inside a flex content column next + // to a 100vh sidebar. Grow to fill that column (so short content doesn't + // leave a bare white void below the card) and carry the page background. + flex: 1, + padding: theme.spacing(3), + backgroundColor: theme.palette.background.default, + overflowX: "hidden", + boxSizing: "border-box", + // The dashboard content area adds 2rem (theme.spacing(4)) of side padding; + // cancel it with negative side margins so the page background is full-bleed + // up to the sidebar instead of sitting in a white gutter. (The root is a + // flex child that stretches to the column width, so the negative margins + // widen it rather than just shifting it.) + marginLeft: theme.spacing(-4), + marginRight: theme.spacing(-4) + }, + backButton: { + marginBottom: theme.spacing(2) + }, + center: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + minHeight: "60vh", + gap: theme.spacing(2) + }, + layout: { + display: "flex", + gap: theme.spacing(3), + alignItems: "flex-start", + // Center the panel + call card as a group within the page. + justifyContent: "center", + // Stack the history panel above the call card on narrow screens. + [theme.breakpoints.down("sm")]: { + flexDirection: "column" + } + }, + historyPanel: { + flex: "0 0 340px", + minWidth: 0, + // Include padding in the width — the app has no CssBaseline, so without this + // a width:100% padded panel overflows its container by the padding amount. + boxSizing: "border-box", + maxHeight: "calc(100vh - 48px)", + overflowY: "auto", + overflowX: "hidden", + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[1], + [theme.breakpoints.down("sm")]: { + flex: "1 1 auto", + width: "100%", + // Flow with the page on mobile instead of being an internal scroll box. + maxHeight: "none", + overflowY: "visible" + } + }, + callColumn: { + // Desktop: at least 600px wide, capped at the card's 720 so it sits next to + // the history panel instead of being centered far to the right. + flex: "1 1 auto", + minWidth: 600, + maxWidth: 720, + // Mobile: full width (the layout stacks at this breakpoint). + [theme.breakpoints.down("sm")]: { + minWidth: 0, + maxWidth: "none", + width: "100%" + } + } +})); + +const VolunteerDialer: React.FC = () => { + const classes = useStyles(); + const history = useHistory(); + const { organizationId, assignmentId } = useParams<{ + organizationId: string; + assignmentId: string; + }>(); + + const [fetchKey, setFetchKey] = useState(0); + // Track whether we've served at least one contact this session + const hasServedContact = useRef(false); + + const { data, loading, error, refetch } = useGetNextDialerContactQuery({ + variables: { assignmentId }, + fetchPolicy: "network-only" + }); + + const handleNextContact = useCallback(() => { + refetch().then(({ data: nextData }) => { + if (!nextData?.getNextDialerContact) { + history.push(`/app/${organizationId}/todos`); + } else { + setFetchKey((k) => k + 1); + } + }); + }, [history, organizationId, refetch]); + + if (loading) { + return ( +
+
+ + Loading contact… +
+
+ ); + } + + if (error) { + return ( +
+
+ + Failed to load contact: {error.message} + +
+
+ ); + } + + const contact = data?.getNextDialerContact; + + if (!loading && !contact) { + if (hasServedContact.current) { + // Finished all contacts — redirect to todos + history.push(`/app/${organizationId}/todos`); + return null; + } + + // No contacts available at all — show an informative message + return ( +
+
+ No contacts to dial + + This assignment has no contacts available. An admin needs to upload + contacts for this calling campaign. + + +
+
+ ); + } + + if (contact) { + hasServedContact.current = true; + } + + return ( +
+ +
+ +
+ +
+
+
+ ); +}; + +export default VolunteerDialer; diff --git a/src/containers/VolunteerDialer/useTelnyxWebRTC.ts b/src/containers/VolunteerDialer/useTelnyxWebRTC.ts new file mode 100644 index 000000000..81bf3ad6c --- /dev/null +++ b/src/containers/VolunteerDialer/useTelnyxWebRTC.ts @@ -0,0 +1,210 @@ +import type { Call, INotification, TelnyxRTC } from "@telnyx/webrtc"; +import { NOTIFICATION_TYPE } from "@telnyx/webrtc"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export type CallState = + | "idle" + | "connecting" + | "ready" + | "dialing" + | "ringing" + | "active" + | "held" + | "ended" + | "error"; + +interface UseTelnyxWebRTCResult { + clientReady: boolean; + callState: CallState; + activeCall: Call | null; + isMuted: boolean; + // Whether the most recent call ever reached the `active` state (someone, or + // a machine, picked up). Used to auto-suggest a disposition. + callWasAnswered: boolean; + // The Telnyx hangup cause of the most recent call (e.g. "USER_BUSY", + // "NO_ANSWER", "NORMAL_CLEARING"), or null if not ended/unknown. + callEndCause: string | null; + // Epoch ms when the current call was answered (reached `active`) and when it + // ended, for computing call duration. Null until each event occurs. + callStartedAt: number | null; + callEndedAt: number | null; + dial: (destinationNumber: string, callerNumber: string) => void; + hangup: () => void; + toggleMute: () => void; + error: string | null; +} + +export const useTelnyxWebRTC = (): UseTelnyxWebRTCResult => { + const clientRef = useRef(null); + const activeCallRef = useRef(null); + const remoteAudioRef = useRef(null); + const wasAnsweredRef = useRef(false); + + const [clientReady, setClientReady] = useState(false); + const [callState, setCallState] = useState("idle"); + const [activeCall, setActiveCall] = useState(null); + const [isMuted, setIsMuted] = useState(false); + const [callWasAnswered, setCallWasAnswered] = useState(false); + const [callEndCause, setCallEndCause] = useState(null); + const [callStartedAt, setCallStartedAt] = useState(null); + const [callEndedAt, setCallEndedAt] = useState(null); + const [error, setError] = useState(null); + + // The Telnyx SDK attaches the remote party's audio stream to this element + // and plays it; without a remoteElement there is no audio output. + useEffect(() => { + const audio = document.createElement("audio"); + audio.autoplay = true; + audio.setAttribute("playsinline", "true"); + audio.style.display = "none"; + document.body.appendChild(audio); + remoteAudioRef.current = audio; + return () => { + audio.remove(); + remoteAudioRef.current = null; + }; + }, []); + + useEffect(() => { + let destroyed = false; + setCallState("connecting"); + + fetch("/telnyx/token") + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch Telnyx credentials"); + return res.json(); + }) + .then(async ({ login_token: loginToken }) => { + if (destroyed) return; + + // Dynamic import so the SDK doesn't run server-side + const { TelnyxRTC: TelnyxRTCClass } = await import("@telnyx/webrtc"); + if (destroyed) return; + + const client = new TelnyxRTCClass({ login_token: loginToken }); + + client.on("telnyx.notification", (notification: INotification) => { + if (notification.type !== NOTIFICATION_TYPE.callUpdate) return; + const { call } = notification; + if (!call) return; + + activeCallRef.current = call; + setActiveCall(call); + + // Map Telnyx numeric state to our display state + const stateLabel: string = (call as any).state ?? ""; + switch (stateLabel) { + case "requesting": + case "trying": + setCallState("dialing"); + break; + case "ringing": + setCallState("ringing"); + break; + case "active": + wasAnsweredRef.current = true; + setCallWasAnswered(true); + setCallStartedAt((prev) => prev ?? Date.now()); + setCallState("active"); + break; + case "held": + setCallState("held"); + break; + case "hangup": + case "destroy": + case "purge": + setCallEndCause((call as any).cause ?? null); + setCallEndedAt((prev) => prev ?? Date.now()); + setCallState("ended"); + activeCallRef.current = null; + setActiveCall(null); + setIsMuted(false); + break; + default: + break; + } + }); + + client.on("telnyx.error", () => { + if (destroyed) return; + setError("Telnyx connection error"); + setCallState("error"); + }); + + clientRef.current = client; + await client.connect(); + + if (!destroyed) { + setClientReady(true); + setCallState("ready"); + } + }) + .catch((err: Error) => { + if (!destroyed) { + setError(err.message); + setCallState("error"); + } + }); + + return () => { + destroyed = true; + if (clientRef.current) { + clientRef.current.disconnect(); + clientRef.current = null; + } + }; + }, []); + + const dial = useCallback( + (destinationNumber: string, callerNumber: string) => { + if (!clientRef.current) return; + wasAnsweredRef.current = false; + setCallWasAnswered(false); + setCallEndCause(null); + setCallStartedAt(null); + setCallEndedAt(null); + setCallState("dialing"); + clientRef.current.newCall({ + destinationNumber, + callerNumber, + audio: true, + video: false, + remoteElement: remoteAudioRef.current ?? undefined + }); + }, + [] + ); + + const hangup = useCallback(() => { + if (activeCallRef.current) { + activeCallRef.current.hangup(); + } + }, []); + + const toggleMute = useCallback(() => { + const call = activeCallRef.current; + if (!call) return; + if (isMuted) { + call.unmuteAudio(); + setIsMuted(false); + } else { + call.muteAudio(); + setIsMuted(true); + } + }, [isMuted]); + + return { + clientReady, + callState, + activeCall, + isMuted, + callWasAnswered, + callEndCause, + callStartedAt, + callEndedAt, + dial, + hangup, + toggleMute, + error + }; +}; diff --git a/src/routes.jsx b/src/routes.jsx index 5707234a8..75c06b3d6 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -41,6 +41,7 @@ import TexterDashboard from "./containers/TexterDashboard"; import TexterTodo from "./containers/TexterTodo"; import TexterTodoList from "./containers/TexterTodoList"; import UserEdit from "./containers/UserEdit"; +import VolunteerDialer from "./containers/VolunteerDialer"; import ApolloClientSingleton from "./network/apollo-client-singleton"; class ProtectedInner extends React.Component { @@ -384,6 +385,12 @@ const TexterOrganizationRoutes = (props) => { component={TexterTodoRoutes} /> + } + topNavTitle="Dialer" + /> + diff --git a/src/schema.graphql b/src/schema.graphql index e14a7aea7..3c74b45c4 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -212,6 +212,10 @@ type OptOutByCampaign { type RootQuery { currentUser: User organization(id:String!, utc:String): Organization + getNextDialerContact(assignmentId: String!): DialerCampaignContact + getDialerContact(dialerCampaignContactId: String!): DialerCampaignContact + dialerContactTextingHistory(dialerCampaignContactId: String!): [DialerContactConversation!]! + callShiftAvailable(organizationId: String!): Boolean! campaign(id:String!): Campaign inviteByHash(hash:String!): [Invite] contact(id:String!): CampaignContact @@ -247,6 +251,12 @@ input SecondPassInput { type RootMutation { createInvite(invite:InviteInput!): Invite + initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! + updateDialerCall(dialerCallId: String!, input: UpdateDialerCallInput!): DialerCall! + saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! + markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! + tagDialerContact(dialerCampaignContactId: String!, tag: ContactTagActionInput!): DialerCampaignContact! + requestCallShift(organizationId: String!): RequestCallShiftResult! createCampaign(campaign:CampaignInput!): Campaign createTemplateCampaign(organizationId: String!): Campaign! deleteTemplateCampaign(organizationId: String!, campaignId: String!): Boolean! @@ -1474,3 +1484,76 @@ type ExternalSyncTagConfigPage { edges: [ExternalSyncTagConfigEdge!]! pageInfo: RelayPageInfo! } + + + +type DialerCampaignContact { + id: ID! + campaignId: ID! + firstName: String! + lastName: String! + zip: String + callStatus: String! + doNotCall: Boolean! + attemptCount: Int! + lastAttemptedAt: Date + customFields: JSON! + assignment: Assignment + interactionSteps: [InteractionStep!]! + questionResponseValues: [DialerQuestionResponseValue!]! + tags: [Tag!]! + campaignVariables: [CampaignVariable!]! +} + +type DialerQuestionResponseValue { + id: ID! + interactionStepId: ID! + question: String! + value: String! +} + +# A past texting conversation with the same person (matched by phone), shown +# as context on the calling screen. One entry per prior campaign_contact. +type DialerContactConversation { + campaignId: ID! + campaignTitle: String! + contactId: ID! + firstName: String + lastName: String + messages: [Message!]! +} + +type DialerCall { + id: ID! + dialerCampaignContactId: ID! + status: String! + fromNumber: String + telnyxCallControlId: String + createdAt: Date! + answeredAt: Date + endedAt: Date +} + +type InitiateCallResult { + dialerCallId: ID! + contactPhone: String! + fromNumber: String! +} + +type RequestCallShiftResult { + assignmentId: ID + campaignId: ID + count: Int! +} + +input DialerQuestionResponseInput { + interactionStepId: String! + value: String! +} + +input UpdateDialerCallInput { + status: String + telnyxCallControlId: String + answeredAt: String + endedAt: String +} diff --git a/src/server/api/assignment.js b/src/server/api/assignment.js index da15edb7d..51b888d4c 100644 --- a/src/server/api/assignment.js +++ b/src/server/api/assignment.js @@ -1183,6 +1183,24 @@ export const resolvers = { .reader("campaign") .where({ id: assignment.campaign_id }) .first(); + + // Call campaigns store contacts in dialer_campaign_contact, which has no + // message_status. Map the form's "needsMessage" filter (not yet handled) + // to call_status = 'not_attempted' (not yet called). + if (campaign.type === "call") { + let query = r + .reader("dialer_campaign_contact") + .where({ + campaign_id: campaign.id, + assignment_id: assignment.id + }) + .whereRaw(`archived = ${campaign.is_archived}`); + if (contactsFilter && contactsFilter.messageStatus === "needsMessage") { + query = query.where("call_status", "not_attempted"); + } + return r.getCount(query); + } + const organization = await r .reader("organization") .where({ id: campaign.organization_id }) diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 671bf8cf0..ade8e9798 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -139,6 +139,17 @@ export const resolvers = { }, CampaignStats: { sentMessagesCount: async (campaign) => { + // Call campaigns have no messages; the "Sent" card is relabeled "Called" + // and shows how many contacts have been called at least once. + if (campaign.type === "call") { + return r.getCount( + r + .reader("dialer_campaign_contact") + .where({ campaign_id: campaign.id }) + .where("attempt_count", ">", 0) + ); + } + const getSentMessagesCount = async ({ campaignId }) => { return r.parseCount( r @@ -387,8 +398,12 @@ export const resolvers = { integration: () => true, contacts: (campaign) => r - .reader("campaign_contact") - .select("campaign_contact.id") + .reader( + campaign.type === "call" + ? "dialer_campaign_contact" + : "campaign_contact" + ) + .select("id") .where({ campaign_id: campaign.id }) .limit(1) .then((records) => records.length > 0), @@ -492,7 +507,7 @@ export const resolvers = { "autosendLimit", "columnMapping" ]), - campaignType: (campaign) => campaign.type.toUpperCase(), + campaignType: (campaign) => (campaign.type ?? "sms").toUpperCase(), isApproved: (campaign) => isNil(campaign.is_approved) ? false : campaign.is_approved, isTemplate: (campaign) => @@ -569,13 +584,21 @@ export const resolvers = { }, contacts: async (campaign) => r - .reader("campaign_contact") + .reader( + campaign.type === "call" + ? "dialer_campaign_contact" + : "campaign_contact" + ) .where({ campaign_id: campaign.id }) .whereRaw(`archived = ${campaign.is_archived}`), // partial index friendly contactsCount: async (campaign) => r.getCount( r - .reader("campaign_contact") + .reader( + campaign.type === "call" + ? "dialer_campaign_contact" + : "campaign_contact" + ) .where({ campaign_id: campaign.id }) .whereRaw(`archived = ${campaign.is_archived}`) // partial index friendly ), @@ -708,7 +731,7 @@ export const resolvers = { }, customFields: async (campaign) => campaign.customFields || - cacheableData.campaign.dbCustomFields(campaign.id), + cacheableData.campaign.dbCustomFields(campaign.id, campaign.type), stats: async (campaign) => campaign, editors: async (campaign, _, { user }) => { if (r.redis) { diff --git a/src/server/api/dialer.ts b/src/server/api/dialer.ts new file mode 100644 index 000000000..c2a0d0d00 --- /dev/null +++ b/src/server/api/dialer.ts @@ -0,0 +1,59 @@ +import { r } from "../models"; +import type { DialerContactWithData } from "./lib/dialer"; +import { sqlResolvers } from "./lib/utils"; +import type { DialerContactRecord } from "./types"; + +export const resolvers = { + DialerCampaignContact: { + ...sqlResolvers([ + "id", + "campaignId", + "firstName", + "lastName", + "zip", + "doNotCall", + "customFields" + ]), + // callStatus/attemptCount/lastAttemptedAt are derived from dialer_call rows + // in getContactWithData (telephony state), not the same-named db columns. + callStatus: (c: DialerContactWithData) => c.callStatus, + attemptCount: (c: DialerContactWithData) => c.attemptCount, + lastAttemptedAt: (c: DialerContactWithData) => c.lastAttemptedAt, + assignment: ( + c: DialerContactRecord, + _args: unknown, + { loaders }: { loaders: any } + ) => (c.assignment_id ? loaders.assignment.load(c.assignment_id) : null), + // Interaction steps are campaign-level; the loader batches and caches them + // per request so multiple contacts in the same campaign share one query. + interactionSteps: ( + c: DialerContactRecord, + _args: unknown, + { loaders }: { loaders: any } + ) => loaders.interactionStepsByCampaign.load(c.campaign_id), + questionResponseValues: (c: DialerContactWithData) => + c.questionResponseValues ?? [], + tags: (c: DialerContactWithData) => c.tags ?? [], + campaignVariables: (c: DialerContactRecord) => + r + .reader("campaign_variable") + .where({ campaign_id: c.campaign_id }) + .whereNull("deleted_at") + .select("*") + }, + + DialerCall: { + ...sqlResolvers([ + "id", + "dialerCampaignContactId", + "status", + "fromNumber", + "telnyxCallControlId", + "createdAt", + "answeredAt", + "endedAt" + ]) + } +}; + +export default resolvers; diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 20f0d54db..4e4622bdd 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -9,6 +9,7 @@ import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import isNil from "lodash/isNil"; import type { QueryResult } from "pg"; +import zipCodeToTimeZone from "zipcode-to-timezone"; import type { RelayPaginatedResponse } from "../../../api/pagination"; import { config } from "../../../config"; @@ -608,8 +609,13 @@ export const editCampaign = async ( ) { await accessRequired(user, organizationId, "ADMIN", /* superadmin */ true); + // A campaign's type is fixed at creation, so the persisted value on + // origCampaignRecord is authoritative for routing the upload. + const isCallCampaign = origCampaignRecord.type === "call"; + // Uploading contacts from a CSV invalidates external system configuration - // and invalidates filtered landlines + // and invalidates filtered landlines. Reset for both campaign types (at + // least until filter-landlines is dropped). await r .knex("campaign") .update({ @@ -618,44 +624,68 @@ export const editCampaign = async ( }) .where({ id }); - const contactsToSave = campaign.contacts.map((datum) => { - const modelData = { + if (isCallCampaign) { + // Call campaigns store contacts in dialer_campaign_contact and are + // dialed by volunteers over WebRTC; they never enter the SMS + // campaign_contact / messaging pipeline (and so skip the + // upload_contacts job, opt-out scrubbing, and landline filtering). + const dialerContacts = campaign.contacts.map((datum) => ({ campaign_id: id, first_name: datum.firstName, last_name: datum.lastName, cell: datum.cell, - external_id: datum.external_id, - custom_fields: datum.customFields, - message_status: "needsMessage", - is_opted_out: false, - zip: datum.zip || "" + external_id: datum.external_id || null, + zip: datum.zip || "", + timezone: datum.zip ? zipCodeToTimeZone.lookup(datum.zip) : null, + custom_fields: JSON.stringify(datum.customFields ?? {}) + })); + + await r.knex.transaction(async (trx) => { + await trx("dialer_campaign_contact") + .where({ campaign_id: id }) + .delete(); + await trx.batchInsert("dialer_campaign_contact", dialerContacts, 1000); + }); + } else { + const contactsToSave = campaign.contacts.map((datum) => { + const modelData = { + campaign_id: id, + first_name: datum.firstName, + last_name: datum.lastName, + cell: datum.cell, + external_id: datum.external_id, + custom_fields: datum.customFields, + message_status: "needsMessage", + is_opted_out: false, + zip: datum.zip || "" + }; + modelData.campaign_id = id; + return modelData; + }); + const jobPayload = { + excludeCampaignIds: campaign.excludeCampaignIds || [], + contacts: contactsToSave, + filterOutLandlines: campaign.filterOutLandlines, + validationStats }; - modelData.campaign_id = id; - return modelData; - }); - const jobPayload = { - excludeCampaignIds: campaign.excludeCampaignIds || [], - contacts: contactsToSave, - filterOutLandlines: campaign.filterOutLandlines, - validationStats - }; - const compressedString: Buffer = (await gzip( - JSON.stringify(jobPayload) - )) as Buffer; - const [job] = await r - .knex("job_request") - .insert({ - queue_name: `${id}:edit_campaign`, - job_type: "upload_contacts", - locks_queue: true, - assigned: JOBS_SAME_PROCESS, // can get called immediately, below - campaign_id: id, - // NOTE: stringifying because compressedString is a binary buffer - payload: compressedString.toString("base64") - }) - .returning("*"); - if (JOBS_SAME_PROCESS) { - uploadContacts(job); + const compressedString: Buffer = (await gzip( + JSON.stringify(jobPayload) + )) as Buffer; + const [job] = await r + .knex("job_request") + .insert({ + queue_name: `${id}:edit_campaign`, + job_type: "upload_contacts", + locks_queue: true, + assigned: JOBS_SAME_PROCESS, // can get called immediately, below + campaign_id: id, + // NOTE: stringifying because compressedString is a binary buffer + payload: compressedString.toString("base64") + }) + .returning("*"); + if (JOBS_SAME_PROCESS) { + uploadContacts(job); + } } } if ( diff --git a/src/server/api/lib/dialer.ts b/src/server/api/lib/dialer.ts new file mode 100644 index 000000000..c52f423d2 --- /dev/null +++ b/src/server/api/lib/dialer.ts @@ -0,0 +1,601 @@ +import { ForbiddenError, UserInputError } from "apollo-server-errors"; +import type { Knex } from "knex"; + +import { config } from "../../../config"; +import { getFormattedPhoneNumber } from "../../../lib/phone-format"; +import { isNowBetween } from "../../../lib/timezones"; +import { r } from "../../models"; +import { OutsideTextingHoursError } from "../../send-message-errors"; +import type { + DialerCallRecord, + DialerContactRecord, + TagRecord, + UserRecord +} from "../types"; +import { getNumberForDial } from "./assemble-numbers"; +import { getMessagingServiceById } from "./message-sending"; + +// A no-answer contact is served again so volunteers can retry, but only up to +// this many dials — otherwise we'd call someone who never picks up endlessly. +export const MAX_DIAL_ATTEMPTS = 3; + +// call_status values that still belong in the dial queue. Keep in sync with the +// literal list in assignDialerShift's FOR UPDATE SKIP LOCKED query (raw SQL +// can't share this array directly). +export const CALLABLE_STATUSES = ["not_attempted", "no_answer"]; + +// The conditions that make a dialer contact callable right now: active, not on +// the do-not-call list, under the dial-attempt cap, and within the campaign's +// contact-hours window (same rule as texting). Shared by every query that +// serves or counts callable contacts so the definition can't drift. Callers +// add their own assignment_id condition (claimed shift vs. unclaimed pool). +// `contactAlias` is the dialer_campaign_contact table/alias; `campaignAlias` +// is a joined/correlated campaign whose timezone + texting-hours columns gate +// the window. +export const applyCallableContactFilter = ( + builder: Knex.QueryBuilder, + contactAlias: string, + campaignAlias: string +): Knex.QueryBuilder => + builder + .where(`${contactAlias}.do_not_call`, false) + .where(`${contactAlias}.archived`, false) + .whereIn(`${contactAlias}.call_status`, CALLABLE_STATUSES) + .where(`${contactAlias}.attempt_count`, "<", MAX_DIAL_ATTEMPTS) + .whereRaw( + `contact_is_textable_now(coalesce(${contactAlias}.timezone, ${campaignAlias}.timezone), ${campaignAlias}.texting_hours_start, ${campaignAlias}.texting_hours_end, true)` + ); + +export interface DialerQuestionResponseValue { + id: number; + interactionStepId: number; + question: string; + value: string; +} + +export interface DialerContactWithData extends DialerContactRecord { + callStatus: string; + attemptCount: number; + lastAttemptedAt: Date | null; + questionResponseValues: DialerQuestionResponseValue[]; + tags: TagRecord[]; +} + +export const getContactWithData = async ( + contact: DialerContactRecord +): Promise => { + // Interaction steps are campaign-level (identical for every contact), so they + // aren't fetched here — the resolver loads them via interactionStepsByCampaign, + // which batches and caches per request. + const [questionResponses, tags, calls] = await Promise.all([ + r + .reader("dialer_question_response") + .join( + "interaction_step as istep", + "dialer_question_response.interaction_step_id", + "istep.id" + ) + .where({ + "dialer_question_response.dialer_campaign_contact_id": contact.id + }) + .select( + "dialer_question_response.id", + "dialer_question_response.interaction_step_id", + "dialer_question_response.value", + "istep.question" + ), + r + .reader("dialer_campaign_contact_tag") + .join("tag", "tag.id", "dialer_campaign_contact_tag.tag_id") + .where({ + "dialer_campaign_contact_tag.dialer_campaign_contact_id": contact.id + }) + .select("tag.*"), + r + .reader("dialer_call") + .where({ dialer_campaign_contact_id: contact.id }) + .orderBy("created_at", "desc") + ]); + + return { + ...contact, + callStatus: calls[0]?.status ?? "NOT_ATTEMPTED", + attemptCount: calls.length, + lastAttemptedAt: calls[0]?.created_at ?? null, + tags, + questionResponseValues: questionResponses.map((qr) => ({ + id: qr.id, + interactionStepId: qr.interaction_step_id, + question: qr.question, + value: qr.value + })) + }; +}; + +const assertContactAccess = async ( + dialerCampaignContactId: string, + user: Pick +): Promise => { + const contact: DialerContactRecord | undefined = await r + .knex("dialer_campaign_contact") + .where({ id: dialerCampaignContactId }) + .first(); + + if (!contact) throw new UserInputError("Dialer contact not found."); + + // Regular volunteers may only act on contacts claimed into their own shift + // (mirrors texting's assignment check); superadmins can access any contact. + if (!user.is_superadmin) { + const assignment = contact.assignment_id + ? await r + .reader("assignment") + .where({ id: contact.assignment_id, user_id: user.id }) + .first() + : null; + if (!assignment) { + throw new ForbiddenError( + "You are not authorized to access that contact." + ); + } + } + + return contact; +}; + +export const getNextDialerContact = async ( + assignmentId: string +): Promise => { + // One query joins through assignment → campaign so the campaign's contact + // hours can be applied without separate round-trips. + // + // Serve only contacts in this volunteer's claimed shift (assignment), not + // the campaign-wide pool — pre-assignment is what prevents two volunteers + // from getting the same contact. + const query = r + .reader("dialer_campaign_contact as cc") + .join("assignment as a", "a.id", "cc.assignment_id") + .join("campaign as c", "c.id", "a.campaign_id") + .where("cc.assignment_id", assignmentId); + applyCallableContactFilter(query, "cc", "c"); + const contact: DialerContactRecord | undefined = await query + .orderBy("cc.id", "asc") + .first("cc.*"); + + if (!contact) return null; + return getContactWithData(contact); +}; + +export const getDialerContact = async ( + dialerCampaignContactId: string, + user: Pick +): Promise => { + const contact = await assertContactAccess(dialerCampaignContactId, user); + return getContactWithData(contact); +}; + +// Correlated EXISTS condition: the campaign (aliased `campaign` in the outer +// query) has at least one unclaimed, callable-now contact. +const whereHasUnclaimedCallableContact = (builder: Knex.QueryBuilder) => { + builder + .select(r.reader.raw(1)) + .from("dialer_campaign_contact as dcc") + .whereRaw("dcc.campaign_id = campaign.id") + .whereNull("dcc.assignment_id"); + applyCallableContactFilter(builder, "dcc", "campaign"); +}; + +// True if the org has any started, autoassign-enabled call campaign with +// unclaimed contacts callable right now — i.e. a shift can be requested. +export const callShiftAvailable = async ( + organizationId: string +): Promise => { + const campaign = await r + .reader("campaign") + .where({ + organization_id: organizationId, + type: "call", + is_started: true, + is_archived: false, + is_autoassign_enabled: true + }) + .whereExists(whereHasUnclaimedCallableContact) + .first("id"); + + return !!campaign; +}; + +// Assign the requesting volunteer a "shift" of up to `count` contacts from an +// autoassign-enabled call campaign, mirroring how texting hands out batches. +// The FOR UPDATE SKIP LOCKED claim guarantees no two volunteers get the same +// contact even under concurrent requests. +export const assignDialerShift = async ( + user: Pick, + organizationId: string, + count: number, + parentTrx = r.knex +): Promise<{ + assignmentId: number | null; + campaignId: number | null; + count: number; +}> => { + return parentTrx.transaction(async (trx) => { + const campaign = await trx("campaign") + .where({ + organization_id: organizationId, + type: "call", + is_started: true, + is_archived: false, + is_autoassign_enabled: true + }) + .whereExists(whereHasUnclaimedCallableContact) + .orderBy("id", "asc") + .first(); + + if (!campaign) { + return { assignmentId: null, campaignId: null, count: 0 }; + } + + let assignment = await trx("assignment") + .where({ user_id: user.id, campaign_id: campaign.id }) + .first(); + + if (!assignment) { + [assignment] = await trx("assignment") + .insert({ user_id: user.id, campaign_id: campaign.id }) + .returning("*"); + } + + // The callable-contact predicate below mirrors applyCallableContactFilter; + // it's inlined as raw SQL because FOR UPDATE SKIP LOCKED can't be expressed + // through the knex builder. Keep the two in sync. + const { rows } = await trx.raw( + ` + with claimed as ( + select id + from dialer_campaign_contact + where campaign_id = ? + and assignment_id is null + and do_not_call = false + and archived = false + and call_status in ('not_attempted', 'no_answer') + and attempt_count < ? + and contact_is_textable_now(coalesce(timezone, ?), ?, ?, true) + order by id asc + for update skip locked + limit ? + ) + update dialer_campaign_contact as dcc + set assignment_id = ? + from claimed + where dcc.id = claimed.id + returning dcc.id; + `, + [ + campaign.id, + MAX_DIAL_ATTEMPTS, + campaign.timezone, + campaign.texting_hours_start, + campaign.texting_hours_end, + count, + assignment.id + ] + ); + + return { + assignmentId: assignment.id, + campaignId: campaign.id, + count: rows.length + }; + }); +}; + +export const initiateCall = async ( + assignmentId: string, + dialerCampaignContactId: string, + user: Pick +): Promise<{ + dialerCallId: number; + contactPhone: string; + fromNumber: string; +}> => { + // Contacts are claimed into a volunteer's shift up-front (see + // assignDialerShift), so the contact must belong to this assignment. + const contact: DialerContactRecord | undefined = await r + .knex("dialer_campaign_contact") + .where({ id: dialerCampaignContactId, assignment_id: assignmentId }) + .first(); + + if (!contact) throw new UserInputError("Contact not found."); + if (contact.do_not_call) + throw new UserInputError("Contact is on the do-not-call list."); + + const campaign = await r + .reader("all_campaign") + .where({ id: contact.campaign_id }) + .first(); + + if (!campaign) throw new UserInputError("Campaign not found."); + + // Calling follows the same contact hours as texting: if it's outside the + // campaign's texting window in the contact's timezone, block the call. + const timezone = contact.timezone || campaign.timezone; + const withinContactHours = isNowBetween( + timezone, + campaign.texting_hours_start, + campaign.texting_hours_end + ); + if (!config.isTest && !withinContactHours) { + throw new OutsideTextingHoursError(); + } + + let fromNumber: string; + + if (config.DEFAULT_SERVICE === "fakeservice") { + // Local/fakeservice testing doesn't have a messaging service to source + // numbers from, so use a configured Telnyx-owned caller ID instead. + if (!config.TELNYX_DEFAULT_FROM_NUMBER) { + throw new Error( + "TELNYX_DEFAULT_FROM_NUMBER must be set to place dialer calls in fakeservice mode." + ); + } + fromNumber = config.TELNYX_DEFAULT_FROM_NUMBER; + } else { + if (!campaign.messaging_service_sid) { + throw new Error("No messaging service configured for this campaign."); + } + + const messagingService = await getMessagingServiceById( + campaign.messaging_service_sid + ); + + const dialResult = await getNumberForDial( + messagingService, + contact.cell, + contact.zip ?? undefined + ); + + fromNumber = dialResult.fromNumber; + } + + // Atomically claim the contact for this call. The conditional status guard + // means a double-click (or any second attempt) updates 0 rows and bails, + // so we never place two calls to the same person. + const claimed = await r + .knex("dialer_campaign_contact") + .where({ id: contact.id }) + .whereIn("call_status", ["not_attempted", "no_answer"]) + .update({ call_status: "in_progress" }); + + if (claimed === 0) { + throw new UserInputError("This contact is no longer available to call."); + } + + const [dialerCall] = (await r + .knex("dialer_call") + .insert({ + dialer_campaign_contact_id: contact.id, + user_id: user.id, + from_number: fromNumber, + status: "QUEUED", + created_at: new Date() + }) + .returning("*")) as DialerCallRecord[]; + + return { + dialerCallId: dialerCall.id, + contactPhone: contact.cell, + fromNumber + }; +}; + +export const updateDialerCall = async ( + dialerCallId: string, + user: Pick, + updates: { + status?: string; + telnyxCallControlId?: string; + answeredAt?: string | null; + endedAt?: string | null; + } +): Promise => { + const existingCall: DialerCallRecord | undefined = await r + .knex("dialer_call") + .where({ id: dialerCallId }) + .first(); + + if (!existingCall) throw new UserInputError("Dialer call not found."); + if (!user.is_superadmin && existingCall.user_id !== user.id) { + throw new ForbiddenError("You are not authorized to update this call."); + } + + const patch: Record = {}; + if (updates.status !== undefined) patch.status = updates.status; + if (updates.telnyxCallControlId !== undefined) + patch.telnyx_call_control_id = updates.telnyxCallControlId; + if (updates.answeredAt !== undefined) + patch.answered_at = updates.answeredAt + ? new Date(updates.answeredAt) + : null; + + // Prefer the real call-end time from the client; otherwise stamp it when the + // call reaches a terminal status. + const terminalStatuses = ["COMPLETED", "NO_ANSWER", "VOICEMAIL", "ERROR"]; + if (updates.endedAt !== undefined) { + patch.ended_at = updates.endedAt ? new Date(updates.endedAt) : null; + } else if (updates.status && terminalStatuses.includes(updates.status)) { + patch.ended_at = new Date(); + } + + const [updated] = (await r + .knex("dialer_call") + .where({ id: dialerCallId }) + .update(patch) + .returning("*")) as DialerCallRecord[]; + + return updated; +}; + +export const saveDialerQuestionResponses = async ( + dialerCampaignContactId: string, + questionResponses: Array<{ interactionStepId: string; value: string }>, + user: Pick +): Promise => { + const contact = await assertContactAccess(dialerCampaignContactId, user); + + for (const qr of questionResponses) { + await r + .knex("dialer_question_response") + .insert({ + dialer_campaign_contact_id: contact.id, + interaction_step_id: qr.interactionStepId, + value: qr.value, + created_at: new Date(), + updated_at: new Date() + }) + .onConflict( + r.knex.raw( + "(interaction_step_id, dialer_campaign_contact_id) WHERE is_deleted = false" + ) + ) + .merge({ value: qr.value, updated_at: new Date() }); + } + + return getContactWithData(contact); +}; + +export const markDialerContactComplete = async ( + dialerCampaignContactId: string, + callStatus: string, + user: Pick +): Promise => { + const contact = await assertContactAccess(dialerCampaignContactId, user); + + const [updated] = (await r + .knex("dialer_campaign_contact") + .where({ id: contact.id }) + .update({ + call_status: callStatus, + attempt_count: r.knex.raw("attempt_count + 1"), + last_attempted_at: new Date(), + // A "do not call" outcome must pin the contact off the dial list. + ...(callStatus === "do_not_call" ? { do_not_call: true } : {}) + }) + .returning("*")) as DialerContactRecord[]; + + return getContactWithData(updated); +}; + +export interface DialerContactConversation { + campaignId: number; + campaignTitle: string; + contactId: number; + firstName: string | null; + lastName: string | null; + messages: unknown[]; +} + +// Cap how many prior conversations we surface, newest first, to keep the +// on-demand history fetch bounded. +const MAX_HISTORY_CONVERSATIONS = 25; + +// A dialer contact's prior texting conversations (same phone, same org), grouped +// by the past campaign_contact. On-demand context for the calling screen. +export const getDialerContactTextingHistory = async ( + dialerCampaignContactId: string, + user: Pick +): Promise => { + const contact = await assertContactAccess(dialerCampaignContactId, user); + + const campaign = await r + .reader("all_campaign") + .where({ id: contact.campaign_id }) + .first("organization_id"); + if (!campaign) return []; + + // Match the same person by normalized phone, scoped to this org only. + const cell = getFormattedPhoneNumber(contact.cell); + + // Index-backed: campaign_contact (cell, campaign_id) then message + // (campaign_contact_id) — avoids an org-wide scan of the message table. + const priorContacts = await r + .reader("campaign_contact") + .join("campaign", "campaign.id", "campaign_contact.campaign_id") + .where({ + "campaign_contact.cell": cell, + "campaign.organization_id": campaign.organization_id + }) + .orderBy("campaign_contact.created_at", "desc") + .limit(MAX_HISTORY_CONVERSATIONS) + .select( + "campaign_contact.id as contact_id", + "campaign_contact.first_name as first_name", + "campaign_contact.last_name as last_name", + "campaign.id as campaign_id", + "campaign.title as campaign_title" + ); + + if (priorContacts.length === 0) return []; + + const contactIds = priorContacts.map((c) => c.contact_id); + const messages = await r + .reader("message") + .whereIn("campaign_contact_id", contactIds) + .orderBy("created_at", "asc"); + + const messagesByContact = new Map(); + for (const message of messages) { + const list = messagesByContact.get(message.campaign_contact_id) ?? []; + list.push(message); + messagesByContact.set(message.campaign_contact_id, list); + } + + // Only surface conversations that actually have messages. + return priorContacts + .map((c) => ({ + campaignId: c.campaign_id, + campaignTitle: c.campaign_title, + contactId: c.contact_id, + firstName: c.first_name, + lastName: c.last_name, + messages: messagesByContact.get(c.contact_id) ?? [] + })) + .filter((conversation) => conversation.messages.length > 0); +}; + +// Apply/remove tags on a dialer contact. Mirrors tagConversation for texting, +// but writes to dialer_campaign_contact_tag (the dialer reuses the shared tag +// vocabulary). The escalation/auto-message behavior of texting tagging does not +// apply to calls, so this only adjusts the tag set. +export const tagDialerContact = async ( + dialerCampaignContactId: string, + addedTagIds: string[], + removedTagIds: string[], + user: Pick +): Promise => { + const contact = await assertContactAccess(dialerCampaignContactId, user); + + if (removedTagIds.length > 0) { + await r + .knex("dialer_campaign_contact_tag") + .where({ dialer_campaign_contact_id: contact.id }) + .whereIn("tag_id", removedTagIds) + .del(); + } + + if (addedTagIds.length > 0) { + await r + .knex("dialer_campaign_contact_tag") + .insert( + addedTagIds.map((tagId) => ({ + dialer_campaign_contact_id: contact.id, + tag_id: parseInt(tagId, 10), + tagger_id: user.id + })) + ) + // Composite PK (contact, tag): re-applying an existing tag is a no-op. + .onConflict(["dialer_campaign_contact_id", "tag_id"]) + .ignore(); + } + + return getContactWithData(contact); +}; diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index 49d2f2e20..72457ae65 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -62,6 +62,14 @@ import { markAutosendingPaused, unqueueAutosending } from "./lib/campaign"; +import { + assignDialerShift, + initiateCall, + markDialerContactComplete, + saveDialerQuestionResponses, + tagDialerContact, + updateDialerCall +} from "./lib/dialer"; import { getSecondPassCampaign } from "./lib/mark-second-pass"; import { saveNewIncomingMessage } from "./lib/message-sending"; import { processNumbers } from "./lib/opt-out"; @@ -1857,12 +1865,46 @@ const rootMutations = { const campaign = await r .knex("campaign") .where({ id: parseInt(campaignId, 10) }) - .first(["organization_id", "is_archived"]); + .first(["organization_id", "is_archived", "type"]); const organizationId = campaign.organization_id; await accessRequired(user, organizationId, "ADMIN", true); + // Call campaigns: delete not-yet-called contacts from dialer_campaign_contact. + // Dependent rows (tags applied before dialing, etc.) have no ON DELETE + // cascade, so clear them first within a transaction. + if (campaign.type === "call") { + const deletedCount = await r.knex.transaction(async (trx) => { + const targetIds = await trx("dialer_campaign_contact") + .where({ + campaign_id: parseInt(campaignId, 10), + call_status: "not_attempted" + }) + .whereRaw(`archived = ${campaign.is_archived}`) + .whereNotExists(function noCalls() { + this.select(trx.raw(1)) + .from("dialer_call") + .whereRaw( + "dialer_call.dialer_campaign_contact_id = dialer_campaign_contact.id" + ); + }) + .pluck("id"); + + if (targetIds.length === 0) return 0; + + await trx("dialer_campaign_contact_tag") + .whereIn("dialer_campaign_contact_id", targetIds) + .del(); + await trx("dialer_question_response") + .whereIn("dialer_campaign_contact_id", targetIds) + .del(); + return trx("dialer_campaign_contact").whereIn("id", targetIds).del(); + }); + + return `Deleted ${deletedCount} uncalled campaign contacts`; + } + /** * deleteNeedsMessage will only delete contacts * if they are currently needsMessage and have NOT been sent a message @@ -2203,6 +2245,27 @@ const rootMutations = { { campaignId, target, ageInHours }, { user: _user } ) => { + const campaign = await r + .knex("campaign") + .where({ id: campaignId }) + .first(["organization_id", "is_archived", "type"]); + + // Call campaigns have no replies to release — only not-yet-called contacts. + // Unassign them back to the autoassign pool (mirrors releasing "unsent"). + if (campaign.type === "call") { + const releasedCount = await r + .knex("dialer_campaign_contact") + .where({ + campaign_id: parseInt(campaignId, 10), + call_status: "not_attempted" + }) + .whereNotNull("assignment_id") + .whereRaw(`archived = ${campaign.is_archived}`) + .update({ assignment_id: null }); + + return `Released ${releasedCount} uncalled contacts for reassignment`; + } + let messageStatus; switch (target) { case "UNSENT": @@ -2223,11 +2286,6 @@ const rootMutations = { ageInHoursAgo = ageInHoursAgo.toISOString(); } - const campaign = await r - .knex("campaign") - .where({ id: campaignId }) - .first(["organization_id", "is_archived"]); - const updatedCount = await r.knex.transaction(async (trx) => { const queryArgs = [parseInt(campaignId, 10), messageStatus]; if (ageInHours) queryArgs.push(ageInHoursAgo); @@ -3331,6 +3389,98 @@ const rootMutations = { }); return true; + }, + + initiateCall: async ( + _root, + { + assignmentId, + dialerCampaignContactId + }: { assignmentId: string; dialerCampaignContactId: string }, + { user }: SpokeRequestContext + ) => { + await assignmentRequired(user, assignmentId); + return initiateCall(assignmentId, dialerCampaignContactId, user); + }, + + requestCallShift: async ( + _root, + { organizationId }: { organizationId: string }, + { user }: SpokeRequestContext + ) => { + await accessRequired(user, organizationId, "TEXTER"); + return assignDialerShift(user, organizationId, config.DIALER_SHIFT_SIZE); + }, + + updateDialerCall: async ( + _root, + { + dialerCallId, + input + }: { + dialerCallId: string; + input: { + status?: string; + telnyxCallControlId?: string; + answeredAt?: string; + endedAt?: string; + }; + }, + { user }: SpokeRequestContext + ) => { + return updateDialerCall(dialerCallId, user, input); + }, + + saveDialerQuestionResponses: async ( + _root, + { + dialerCampaignContactId, + questionResponses + }: { + dialerCampaignContactId: string; + questionResponses: Array<{ interactionStepId: string; value: string }>; + }, + { user }: SpokeRequestContext + ) => { + return saveDialerQuestionResponses( + dialerCampaignContactId, + questionResponses, + user + ); + }, + + markDialerContactComplete: async ( + _root, + { + dialerCampaignContactId, + callStatus + }: { dialerCampaignContactId: string; callStatus: string }, + { user }: SpokeRequestContext + ) => { + return markDialerContactComplete( + dialerCampaignContactId, + callStatus, + user + ); + }, + + tagDialerContact: async ( + _root, + { + dialerCampaignContactId, + tag + }: { + dialerCampaignContactId: string; + tag: { addedTagIds: string[]; removedTagIds: string[] }; + }, + { user }: SpokeRequestContext + ) => { + return tagDialerContact( + dialerCampaignContactId, + tag.addedTagIds, + tag.removedTagIds, + user + ); } } }; diff --git a/src/server/api/root-resolvers.ts b/src/server/api/root-resolvers.ts index 39787b86f..a871f1ad5 100644 --- a/src/server/api/root-resolvers.ts +++ b/src/server/api/root-resolvers.ts @@ -12,8 +12,19 @@ import { r } from "../models"; import { getCampaigns } from "./campaign"; import { queryCampaignOverlaps } from "./campaign-overlap"; import { getConversations } from "./conversations"; -import { accessRequired, authRequired, superAdminRequired } from "./errors"; +import { + accessRequired, + assignmentRequired, + authRequired, + superAdminRequired +} from "./errors"; import { getStepsToUpdate } from "./lib/bulk-script-editor"; +import { + callShiftAvailable as callShiftAvailableLib, + getDialerContact, + getDialerContactTextingHistory, + getNextDialerContact +} from "./lib/dialer"; import { formatPage } from "./lib/pagination"; import { getUsers, getUsersById } from "./user"; @@ -524,6 +535,36 @@ const rootResolvers = { }; }); }, + getNextDialerContact: async (_root, { assignmentId }, { user }) => { + await assignmentRequired(user, assignmentId); + return getNextDialerContact(assignmentId); + }, + + getDialerContact: async ( + _root, + { dialerCampaignContactId }: { dialerCampaignContactId: string }, + { user } + ) => { + return getDialerContact(dialerCampaignContactId, user); + }, + + dialerContactTextingHistory: async ( + _root, + { dialerCampaignContactId }: { dialerCampaignContactId: string }, + { user } + ) => { + return getDialerContactTextingHistory(dialerCampaignContactId, user); + }, + + callShiftAvailable: async ( + _root, + { organizationId }: { organizationId: string }, + { user } + ) => { + await accessRequired(user, organizationId, "TEXTER"); + return callShiftAvailableLib(organizationId); + }, + isValidAttachment: async (_root, { fileUrl }, _context) => { // 2025-03-25: @npcz/magic is throwing an uncatachable exception // skip file type validation for now diff --git a/src/server/api/schema.ts b/src/server/api/schema.ts index 1974e5c1d..d943673d5 100644 --- a/src/server/api/schema.ts +++ b/src/server/api/schema.ts @@ -11,6 +11,7 @@ import { resolvers as campaignGroupResolvers } from "./campaign-group"; import { resolvers as campaignVariableResolvers } from "./campaign-variable"; import { resolvers as cannedResponseResolvers } from "./canned-response"; import { resolvers as conversationsResolver } from "./conversations"; +import { resolvers as dialerResolvers } from "./dialer"; import { resolvers as externalActivistCodeResolvers } from "./external-activist-code"; import { resolvers as externalListResolvers } from "./external-list"; import { resolvers as externalResultCodeResolvers } from "./external-result-code"; @@ -76,6 +77,7 @@ export const resolvers = { ...{ Upload: GraphQLUpload }, ...questionResolvers, ...conversationsResolver, + ...dialerResolvers, ...rootMutations }; diff --git a/src/server/api/types.ts b/src/server/api/types.ts index ec404f67e..4dce94bd4 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -325,6 +325,34 @@ export interface TagRecord { deleted_at: string; } +export interface DialerContactRecord { + id: number; + campaign_id: number; + assignment_id: number | null; + external_id: string | null; + first_name: string; + last_name: string; + cell: string; + zip: string | null; + timezone: string | null; + custom_fields: Record; + do_not_call: boolean; + archived: boolean; + created_at: Date; + updated_at: Date; +} + +export interface DialerCallRecord { + id: number; + dialer_campaign_contact_id: number; + user_id: number; + telnyx_call_control_id: string | null; + from_number: string | null; + status: string; + created_at: Date; + ended_at: Date | null; +} + export interface UserRecord { id: number; auth0_id: string; diff --git a/src/server/api/user.js b/src/server/api/user.js index 215cffe7b..53c259e7e 100644 --- a/src/server/api/user.js +++ b/src/server/api/user.js @@ -5,6 +5,7 @@ import groupBy from "lodash/groupBy"; import { UserRoleType } from "../../api/organization-membership"; import { r } from "../models"; import { accessRequired } from "./errors"; +import { applyCallableContactFilter } from "./lib/dialer"; import { formatPage } from "./lib/pagination"; import { sqlResolvers } from "./lib/utils"; @@ -320,9 +321,49 @@ export const resolvers = { (todo) => todo.assignment_id ); + // Call campaigns store contacts in dialer_campaign_contact (not + // campaign_contact), so the query above never surfaces them. Pull their + // assignments in directly; they carry no shadow counts (the dialer UI + // works off the contacts claimed into the shift). Only include an + // assignment that still has contacts in the volunteer's shift to call + // right now, otherwise the todo (and its "Start Calling" button) + // shouldn't appear. + const callAssignmentIds = await r + .reader("assignment") + .join("campaign", "campaign.id", "assignment.campaign_id") + .where({ + "assignment.user_id": user.id, + "campaign.organization_id": organizationId, + "campaign.type": "call", + "campaign.is_started": true, + "campaign.is_archived": false + }) + .whereExists(function shiftContactsExist() { + this.select(r.reader.raw(1)) + .from("dialer_campaign_contact") + // Contacts claimed into this volunteer's shift. + .whereRaw("dialer_campaign_contact.assignment_id = assignment.id"); + // Don't surface the todo when nobody in the shift is callable now. + applyCallableContactFilter( + this, + "dialer_campaign_contact", + "campaign" + ); + }) + .pluck("assignment.id"); + + const assignmentIds = [ + ...new Set([ + ...Object.keys(shadowCountsByAssignmentId).map((id) => + parseInt(id, 10) + ), + ...callAssignmentIds + ]) + ]; + const assignments = await r .reader("assignment") - .whereIn("id", Object.keys(shadowCountsByAssignmentId)) + .whereIn("id", assignmentIds) .orderBy("updated_at", "desc"); return assignments.map((a) => diff --git a/src/server/app.ts b/src/server/app.ts index e26a87edb..3833d0320 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -24,6 +24,7 @@ import { nexmoRouter, previewRouter, settingsRouter, + telnyxRouter, twilioRouter, utilsRouter } from "./routes"; @@ -135,6 +136,7 @@ export const createApp = async () => { app.use(nexmoRouter); app.use(twilioRouter); app.use(assembleRouter); + app.use(telnyxRouter); app.use(utilsRouter); app.use(previewRouter); app.use(settingsRouter); diff --git a/src/server/models/cacheable_queries/campaign.js b/src/server/models/cacheable_queries/campaign.js index 9abda3afc..09cf94de3 100644 --- a/src/server/models/cacheable_queries/campaign.js +++ b/src/server/models/cacheable_queries/campaign.js @@ -21,14 +21,18 @@ const { r } = thinky; const cacheKey = (id) => `${config.CACHE_PREFIX}campaign-${id}`; -const dbCustomFields = async (id) => { - const campaignContact = await r - .reader("campaign_contact") +const dbCustomFields = async (id, type) => { + const contact = await r + .reader(type === "call" ? "dialer_campaign_contact" : "campaign_contact") .where({ campaign_id: id }) .first("custom_fields"); - if (campaignContact) { - const customFields = JSON.parse(campaignContact.custom_fields || "{}"); + if (contact) { + // campaign_contact.custom_fields is text; dialer_campaign_contact's is + // jsonb, which knex returns already parsed. + const raw = contact.custom_fields; + const customFields = + typeof raw === "string" ? JSON.parse(raw || "{}") : raw ?? {}; return Object.keys(customFields); } @@ -56,7 +60,7 @@ const loadDeep = async (id) => { await clear(id); return campaign; } - campaign.customFields = await dbCustomFields(id); + campaign.customFields = await dbCustomFields(id, campaign.type); campaign.interactionSteps = await dbInteractionSteps(id); // We should only cache organization data // if/when we can clear it on organization data changes diff --git a/src/server/models/index.ts b/src/server/models/index.ts index 1e985ffee..1536170b9 100644 --- a/src/server/models/index.ts +++ b/src/server/models/index.ts @@ -45,6 +45,30 @@ const createLoader = ( }); }; +/** + * Like createLoader, but batches by a non-unique foreign key and returns the + * full list of matching rows per key (empty array when none match). Useful for + * one-to-many relationships, e.g. all interaction steps for a campaign. + * + * @param {string} tableName The database table name to load from + * @param {string} foreignKey The column to batch and group by + * @param {function} applyScope Optional extra query scoping (e.g. soft-delete) + */ +const createListLoader = ( + context: SpokeContext, + tableName: string, + foreignKey: string, + applyScope?: (query: any) => any +) => { + const { db } = context; + return new DataLoader(async (keys) => { + const baseQuery = db.reader(tableName).whereIn(foreignKey, keys); + const docs = await (applyScope ? applyScope(baseQuery) : baseQuery); + const docsByKey = groupBy(docs, foreignKey); + return keys.map((key) => docsByKey[key] ?? []); + }); +}; + const createLoaders = (context: SpokeContext) => ({ assignment: createLoader(context, "assignment"), assignmentRequest: createLoader(context, "assignment_request"), @@ -56,6 +80,12 @@ const createLoaders = (context: SpokeContext) => ({ campaignTeam: createLoader(context, "campaign_team"), cannedResponse: createLoader(context, "canned_response"), interactionStep: createLoader(context, "interaction_step"), + interactionStepsByCampaign: createListLoader( + context, + "interaction_step", + "campaign_id", + (query) => query.where({ is_deleted: false }) + ), invite: createLoader(context, "invite"), jobRequest: createLoader(context, "job_request"), linkDomain: createLoader(context, "link_domain"), diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index d5b3a210e..9cfaa56b8 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -4,6 +4,7 @@ import previewRouter from "./campaign-preview"; import { createRouter as createGraphqlRouter } from "./graphql"; import nexmoRouter from "./nexmo"; import settingsRouter from "./settings"; +import telnyxRouter from "./telnyx"; import twilioRouter from "./twilio"; import utilsRouter from "./utils"; @@ -14,6 +15,7 @@ export { twilioRouter, assembleRouter, settingsRouter, + telnyxRouter, utilsRouter, previewRouter }; diff --git a/src/server/routes/telnyx.ts b/src/server/routes/telnyx.ts new file mode 100644 index 000000000..0d5468880 --- /dev/null +++ b/src/server/routes/telnyx.ts @@ -0,0 +1,94 @@ +import express from "express"; +import superagent from "superagent"; + +import { config } from "../../config"; +import logger from "../../logger"; +import { r } from "../models"; +import type { SpokeRequest } from "../types"; +import { errToObj } from "../utils"; + +const router = express.Router(); + +// Mints a short-lived Telnyx WebRTC access token (JWT) for the logged-in user. +// The Telnyx API key and SIP credentials never leave the server; the browser +// only ever receives an ephemeral, scoped token to log in to TelnyxRTC. +router.get("/telnyx/token", async (req, res) => { + const spokeReq = req as SpokeRequest; + if (!spokeReq.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { TELNYX_API_KEY, TELNYX_TELEPHONY_CREDENTIAL_ID } = config; + if (!TELNYX_API_KEY || !TELNYX_TELEPHONY_CREDENTIAL_ID) { + return res + .status(503) + .json({ error: "Telnyx calling is not configured on this server." }); + } + + try { + const response = await superagent + .post( + `https://api.telnyx.com/v2/telephony_credentials/${TELNYX_TELEPHONY_CREDENTIAL_ID}/token` + ) + .set("Authorization", `Bearer ${TELNYX_API_KEY}`); + + // The token endpoint returns the JWT as a plain-text body. + const loginToken = response.text?.trim(); + if (!loginToken) { + throw new Error("Telnyx returned an empty access token"); + } + + return res.json({ login_token: loginToken }); + } catch (err: any) { + logger.error("Error minting Telnyx access token", { ...errToObj(err) }); + return res + .status(502) + .json({ error: "Could not obtain a Telnyx access token." }); + } +}); + +// Telnyx Call Control webhook — updates dialer_call rows as call state changes +router.post("/telnyx/call-control", async (req, res) => { + const { data } = req.body ?? {}; + if (!data) return res.status(400).json({ error: "Missing event data" }); + + const { event_type, payload } = data; + const callControlId: string | undefined = payload?.call_control_id; + if (!callControlId) return res.status(200).send(); + + try { + const statusMap: Record = { + "call.initiated": "DIALING", + "call.answered": "IN_PROGRESS", + "call.hangup": "COMPLETED" + }; + + const newStatus = statusMap[event_type]; + if (!newStatus) return res.status(200).send(); + + const updates: Record = { + telnyx_call_control_id: callControlId, + status: newStatus + }; + + if (newStatus === "COMPLETED") { + updates.ended_at = new Date(); + } + + await r + .knex("dialer_call") + .where({ telnyx_call_control_id: callControlId }) + .update(updates); + + return res.status(200).send(); + } catch (err: any) { + logger.error("Error handling Telnyx call-control webhook", { + ...errToObj(err), + event_type, + callControlId + }); + return res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/src/server/send-message-errors.ts b/src/server/send-message-errors.ts index ed66652a1..0858c131c 100644 --- a/src/server/send-message-errors.ts +++ b/src/server/send-message-errors.ts @@ -5,7 +5,7 @@ export class SendTimeMessagingError extends GraphQLError {} export class OutsideTextingHoursError extends SendTimeMessagingError { constructor() { - super("Outside permitted texting time for this recipient"); + super("Outside permitted contact time for this recipient"); } } diff --git a/src/server/tasks/assign-texters.ts b/src/server/tasks/assign-texters.ts index 4af2058cd..a3b302d59 100644 --- a/src/server/tasks/assign-texters.ts +++ b/src/server/tasks/assign-texters.ts @@ -17,6 +17,37 @@ export interface AssignmentTarget { operation: string; } +// Texting is the default everywhere (campaign_contact + the message_status +// ordering baked into the per-stage option defaults). Only call campaigns +// override the table and assignable rules. +interface ContactTableConfig { + table?: string; + // Extra SQL predicate (with a leading "and ") restricting which unassigned + // contacts may be handed out. + assignableFilter?: string; + // ORDER BY expression deciding which assignable contacts go out first. + assignableOrder?: string; +} + +// Admin push-assignment writes the same assignment_id column the volunteer +// shift/pull path uses, so the two coexist: claimed contacts (non-null +// assignment_id) are invisible to assignDialerShift, which only claims nulls. +const getContactTableConfig = ( + campaignType: string | null | undefined +): ContactTableConfig => + campaignType === "call" + ? { + table: "dialer_campaign_contact", + // Hand out only contacts that still need a call attempt — never + // already-finished (answered/voicemail) or do-not-call contacts. + assignableFilter: + "and do_not_call = false and call_status in ('not_attempted', 'no_answer')", + // Prioritize never-attempted contacts over no-answer retries. + assignableOrder: + "(case when call_status = 'not_attempted' then 10 else 20 end) asc" + } + : {}; + interface EnsureAssignmentsOptions { client: PoolClient | Pool; campaignId: number; @@ -54,6 +85,7 @@ export const ensureAssignments = async (options: EnsureAssignmentsOptions) => { interface ZeroOutDeletedOptions { client: PoolClient | Pool; + table?: string; campaignId: number; isArchived: boolean; assignmentIds: number[]; @@ -63,6 +95,7 @@ interface ZeroOutDeletedOptions { export const zeroOutDeleted = async (options: ZeroOutDeletedOptions) => { const { client, + table = "campaign_contact", campaignId, isArchived, assignmentIds, @@ -70,7 +103,7 @@ export const zeroOutDeleted = async (options: ZeroOutDeletedOptions) => { } = options; await client.query( ` - update campaign_contact + update ${table} set assignment_id = null where campaign_id = $1 @@ -85,6 +118,7 @@ export const zeroOutDeleted = async (options: ZeroOutDeletedOptions) => { interface FreeUpTextersOptions { client: PoolClient; + table?: string; campaignId: number; isArchived: boolean; assignmentTargets: AssignmentTarget[]; @@ -94,6 +128,7 @@ interface FreeUpTextersOptions { export const freeUpTexters = async (options: FreeUpTextersOptions) => { const { client, + table = "campaign_contact", campaignId, isArchived, assignmentTargets, @@ -106,7 +141,7 @@ export const freeUpTexters = async (options: FreeUpTextersOptions) => { ` with cc_ids_to_keep as ( select id - from campaign_contact + from ${table} where campaign_id = $1 and archived = ${isArchived} @@ -114,7 +149,7 @@ export const freeUpTexters = async (options: FreeUpTextersOptions) => { order by id asc limit $3 ) - update campaign_contact + update ${table} set assignment_id = null where campaign_id = $4 @@ -136,13 +171,32 @@ export const freeUpTexters = async (options: FreeUpTextersOptions) => { interface AssignPayloadsOptions { client: PoolClient; + table?: string; + assignableFilter?: string; + assignableOrder?: string; campaignId: number; isArchived: boolean; assignmentTargets: AssignmentTarget[]; } export const assignPayloads = async (options: AssignPayloadsOptions) => { - const { client, campaignId, isArchived, assignmentTargets } = options; + const { + client, + table = "campaign_contact", + assignableFilter = "", + // Texting default: prioritize conversations that need action. + assignableOrder = `(case + when message_status = 'needsMessage' then 10 + when message_status = 'needsResponse' then 20 + when message_status = 'convo' then 30 + when message_status = 'messaged' then 40 + when message_status = 'closed' then 50 + else 60 + end) asc`, + campaignId, + isArchived, + assignmentTargets + } = options; const assignmentIds = assignmentTargets.map(({ id }) => parseInt(id, 10)); const contactsCounts = assignmentTargets.map( @@ -158,11 +212,11 @@ export const assignPayloads = async (options: AssignPayloadsOptions) => { select assignment_id, generate_series(1, desired_count - ( - select count(*) from campaign_contact + select count(*) from ${table} where campaign_id = $3 and archived = ${isArchived} - and campaign_contact.assignment_id = raw_assignments.assignment_id + and ${table}.assignment_id = raw_assignments.assignment_id )) from raw_assignments ), @@ -175,32 +229,26 @@ export const assignPayloads = async (options: AssignPayloadsOptions) => { assignable_contacts as ( select row_number() over () as row, - id as campaign_contact_id - from campaign_contact + id as contact_id + from ${table} where campaign_id = $3 and archived = ${isArchived} and assignment_id is null + ${assignableFilter} order by - -- prioritize conversations requiring action - (case - when message_status = 'needsMessage' then 10 - when message_status = 'needsResponse' then 20 - when message_status = 'convo' then 30 - when message_status = 'messaged' then 40 - when message_status = 'closed' then 50 - else 60 - end) asc + -- prioritize contacts requiring action + ${assignableOrder} ), final_payloads as ( - select ap.assignment_id, ac.campaign_contact_id + select ap.assignment_id, ac.contact_id from assignments_payload ap join assignable_contacts ac on ac.row = ap.row ) - update campaign_contact cc + update ${table} cc set assignment_id = fp.assignment_id from final_payloads fp - where cc.id = fp.campaign_contact_id + where cc.id = fp.contact_id `, [assignmentIds, contactsCounts, campaignId] ); @@ -259,6 +307,12 @@ export const assignTexters: ProgressTask = async ( ]) .then(({ rows: [row] }) => row); + // Texting campaigns assign campaign_contact rows; call campaigns assign + // dialer_campaign_contact rows. Everything else is shared. + const { table, assignableFilter, assignableOrder } = getContactTableConfig( + campaign.type + ); + const targets = await helpers.withPgClient((poolClient) => withTransaction(poolClient, async (trx) => { // Ensure assignments for all texters @@ -273,6 +327,7 @@ export const assignTexters: ProgressTask = async ( const assignmentIds = assignmentTargets.map(({ id }) => parseInt(id, 10)); await zeroOutDeleted({ client: trx, + table, campaignId, isArchived: campaign.is_archived ?? false, assignmentIds, @@ -283,6 +338,7 @@ export const assignTexters: ProgressTask = async ( // Free up contacts from assignment counts that have decreased await freeUpTexters({ client: trx, + table, campaignId, isArchived: campaign.is_archived ?? false, assignmentTargets, @@ -294,6 +350,9 @@ export const assignTexters: ProgressTask = async ( // Assign desired payloads to texters await assignPayloads({ client: trx, + table, + assignableFilter, + assignableOrder, campaignId, isArchived: campaign.is_archived ?? false, assignmentTargets diff --git a/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts b/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts index 0bb12c033..bc2e266c4 100644 --- a/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts +++ b/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts @@ -36,6 +36,11 @@ const cs = new pgp.helpers.ColumnSet( } ); +const dialerCs = new pgp.helpers.ColumnSet( + ["campaign_id", "first_name", "last_name", "cell", "zip", "custom_fields"], + { table: "dialer_campaign_contact" } +); + type CampaignContactInsertRow = Pick< CampaignContactRecord, | "campaign_id" @@ -138,6 +143,14 @@ export const importContactCsvFromUrl: Task = async ( downloadReq.pipe(csvStream); await helpers.withPgClient(async (client) => { + const { + rows: [campaign] + } = await client.query<{ type: string }>( + `select type from campaign where id = $1`, + [campaignId] + ); + const isCallCampaign = campaign?.type === "call"; + const { rows: [{ id: jobId }] } = await client.query<{ id: string }>( @@ -163,13 +176,23 @@ export const importContactCsvFromUrl: Task = async ( ); await withTransaction(client, async (trx) => { + // Uploading contacts invalidates external system config and filtered + // landlines for both campaign types (at least until filter-landlines + // is dropped). await trx.query( `update campaign set external_system_id = null, landlines_filtered = false where id = $1`, [campaignId] ); - await trx.query(`delete from campaign_contact where campaign_id = $1`, [ - campaignId - ]); + if (isCallCampaign) { + await trx.query( + `delete from dialer_campaign_contact where campaign_id = $1`, + [campaignId] + ); + } else { + await trx.query(`delete from campaign_contact where campaign_id = $1`, [ + campaignId + ]); + } const accumulator: CampaignContactInsertRow[] = []; for await (const row of csvStream) { @@ -181,16 +204,30 @@ export const importContactCsvFromUrl: Task = async ( [] ); - await insertBatch(trx, validatedData); - - const optOutCount = await deleteOptedOutContacts(trx, campaignId); + let optOutCount = 0; + if (isCallCampaign) { + const dialerRows = validatedData.map((r) => ({ + campaign_id: r.campaign_id, + first_name: r.first_name, + last_name: r.last_name, + cell: r.cell, + zip: r.zip ?? null, + custom_fields: r.custom_fields + })); + if (dialerRows.length > 0) { + const query = pgp.helpers.insert(dialerRows, dialerCs); + await trx.query(query); + } + } else { + await insertBatch(trx, validatedData); + optOutCount = await deleteOptedOutContacts(trx, campaignId); + } const jobMessages = await getContactResultMessage({ ...validationStats, optOutCount }); - // Always set a result message to mark the job as complete const message = jobMessages.length > 0 ? jobMessages.join("\n") diff --git a/yarn.lock b/yarn.lock index 142e64942..ff5f53e00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6200,6 +6200,14 @@ dependencies: "@passport-next/passport-strategy" "1.x.x" +"@peermetrics/webrtc-stats@^5.7.1": + version "5.9.0" + resolved "https://registry.yarnpkg.com/@peermetrics/webrtc-stats/-/webrtc-stats-5.9.0.tgz#cb6a2f32e2bc4d5abe0977f3635ecd83ed09e7ad" + integrity sha512-eQYGGdj+H4MUEuwbccy9bxRV3uAqPM5+Say9PSx/alrtv5ccmKCqGfLbqXrRJ/qThZvjZNBna5K/YnCWoaTQBw== + dependencies: + events "^3.3.0" + uuid "^8.3.2" + "@pmmmwh/react-refresh-webpack-plugin@0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.2.tgz#1f9741e0bde9790a0e13272082ed7272a083620d" @@ -6637,6 +6645,15 @@ dependencies: defer-to-connect "^2.0.0" +"@telnyx/webrtc@^2.27.1": + version "2.27.1" + resolved "https://registry.yarnpkg.com/@telnyx/webrtc/-/webrtc-2.27.1.tgz#2ee16fc40ca9edb6ac4783a5829d7e26349537a6" + integrity sha512-fjsMTX/srcskv2O5s2tqXVzRHu8QuBDiF8igtpKi9E2yxD+A5hjD/42GjIfUTl65kIW50K2ZL3eI6Za6Sq2+Fw== + dependencies: + "@peermetrics/webrtc-stats" "^5.7.1" + loglevel "^1.6.8" + uuid "^7.0.3" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -28648,7 +28665,12 @@ uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^8.0.0, uuid@^8.3.0: +uuid@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + +uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== From 6cb3e4e22fd63736e4528941f53c0dec0a5f4d82 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Wed, 17 Jun 2026 06:08:22 -0700 Subject: [PATCH 10/10] chore(dialer): fix down migration for campaign type campaign view --- .../20260601000001_add-campaign-type.js | 139 +++++++++++++++--- 1 file changed, 122 insertions(+), 17 deletions(-) diff --git a/migrations/20260601000001_add-campaign-type.js b/migrations/20260601000001_add-campaign-type.js index de9cd3a68..d81187723 100644 --- a/migrations/20260601000001_add-campaign-type.js +++ b/migrations/20260601000001_add-campaign-type.js @@ -74,20 +74,14 @@ exports.down = async function down(knex) { drop constraint if exists call_campaigns_no_stale_release; `); - // Remove type from the campaign view before dropping the column. - // create or replace view cannot remove columns, so we must drop and recreate - // the view and all views that depend on it. + // Removing the type column requires dropping and recreating the campaign view, + // which cascades to EVERY dependent view: not just the autosend/assignable + // stack, but also assignable_campaign_contacts and the external-sync + // configuration views. Drop with cascade and rebuild the full stack to its + // pre-type definition (mirrors 20250912015240_drop_dynamic_assignment). await knex.raw(` - drop view if exists - autosend_campaigns_to_send, - assignable_needs_reply_with_escalation_tags, - assignable_campaigns_with_needs_reply, - assignable_campaigns_with_needs_message, - assignable_needs_reply, - assignable_needs_message, - assignable_campaigns, - sendable_campaigns, - campaign; + drop view campaign cascade; + alter table all_campaign drop column type; create view campaign as select @@ -100,6 +94,121 @@ exports.down = async function down(knex) { from all_campaign where is_template = false; + create view assignable_campaign_contacts as + select + campaign_contact.id, campaign_contact.campaign_id, + campaign_contact.message_status, campaign.texting_hours_end, + campaign_contact.timezone::text as contact_timezone + from campaign_contact + join campaign on campaign_contact.campaign_id = campaign.id + where assignment_id is null + and is_opted_out = false + and archived = false + 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 + ); + + create view public.missing_external_sync_question_response_configuration as + select + all_values.*, + external_system.id as system_id + from ( + select + istep.campaign_id, + istep.parent_interaction_id as interaction_step_id, + istep.answer_option as value, + exists ( + select 1 + from public.question_response as istep_qr + where + istep_qr.interaction_step_id = istep.parent_interaction_id + and istep_qr.value = istep.answer_option + ) as is_required + from public.interaction_step istep + where istep.parent_interaction_id is not null + union + select + qr_istep.campaign_id, + qr.interaction_step_id, + qr.value, + true as is_required + from public.question_response as qr + join public.interaction_step qr_istep on qr_istep.id = qr.interaction_step_id + ) all_values + join campaign on campaign.id = all_values.campaign_id + join external_system + on external_system.organization_id = campaign.organization_id + where + not exists ( + select 1 + from public.all_external_sync_question_response_configuration aqrc + where + all_values.campaign_id = aqrc.campaign_id + and external_system.id = aqrc.system_id + and all_values.interaction_step_id = aqrc.interaction_step_id + and all_values.value = aqrc.question_response_value + ); + + create view public.external_sync_question_response_configuration as + select + aqrc.id::text as compound_id, + aqrc.campaign_id, + aqrc.system_id, + aqrc.interaction_step_id, + aqrc.question_response_value, + aqrc.created_at, + aqrc.updated_at, + not exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + where qrro.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_activist_code qrac + where qrac.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_result_code qrrc + where qrrc.question_response_config_id = aqrc.id + ) as is_empty, + exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + join external_survey_question_response_option + on external_survey_question_response_option.id = qrro.external_response_option_id + join external_survey_question + on external_survey_question.id = external_survey_question_response_option.external_survey_question_id + where + qrro.question_response_config_id = aqrc.id + and external_survey_question.status <> 'active' + + union + + select 1 from public.external_sync_config_question_response_activist_code qrac + join external_activist_code + on external_activist_code.id = qrac.external_activist_code_id + where + qrac.question_response_config_id = aqrc.id + and external_activist_code.status <> 'active' + ) as includes_not_active, + false as is_missing, + false as is_required + from public.all_external_sync_question_response_configuration aqrc + union + select + missing.value || '|' || missing.interaction_step_id || '|' || missing.campaign_id as compound_id, + missing.campaign_id, + missing.system_id as system_id, + missing.interaction_step_id, + missing.value as question_response_value, + null as created_at, + null as updated_at, + true as is_empty, + false as includes_not_active, + true as is_missing, + missing.is_required + from public.missing_external_sync_question_response_configuration missing; + create view sendable_campaigns as select campaign.id, campaign.title, campaign.organization_id, campaign.limit_assignment_to_teams, campaign.autosend_status, @@ -209,8 +318,4 @@ exports.down = async function down(knex) { ) and sendable_campaigns.autosend_status = 'sending'; `); - - await knex.schema.alterTable("all_campaign", (table) => { - table.dropColumn("type"); - }); };