From dfea9e9c02d4e8825b75fe4daf388b338186be62 Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Wed, 10 Jun 2026 12:02:54 -0700 Subject: [PATCH 1/3] 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 --- libs/gql-schema/dialer.ts | 55 +++ libs/gql-schema/schema.ts | 12 +- ...01000002_create-dialer-campaign-contact.js | 10 +- .../20260601000003_create-dialer-call.js | 3 +- ...0005_create-dialer-campaign-contact-tag.js | 5 - .../20260601000007_campaign-view-add-type.js | 70 +++ .../20260601000008_dialer-call-add-timing.js | 23 + ...1000009_call-campaigns-allow-autoassign.js | 26 + ...0601000010_dialer-contact-call-tracking.js | 33 ++ schema-dump.sql | 16 +- src/config.js | 20 + src/schema.graphql | 62 +++ src/server/api/campaign.js | 27 +- src/server/api/dialer.ts | 44 ++ src/server/api/lib/campaign.ts | 113 +++-- src/server/api/lib/dialer.ts | 463 ++++++++++++++++++ src/server/api/root-mutations.ts | 86 ++++ src/server/api/root-resolvers.ts | 34 +- src/server/api/schema.ts | 2 + src/server/api/types.ts | 28 ++ src/server/api/user.js | 47 +- src/server/app.ts | 2 + .../models/cacheable_queries/campaign.js | 16 +- src/server/routes/index.ts | 2 + src/server/routes/telnyx.ts | 94 ++++ src/server/send-message-errors.ts | 2 +- .../import-contact-csv-from-url.ts | 56 ++- 27 files changed, 1261 insertions(+), 90 deletions(-) create mode 100644 libs/gql-schema/dialer.ts create mode 100644 migrations/20260601000007_campaign-view-add-type.js 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/server/api/dialer.ts create mode 100644 src/server/api/lib/dialer.ts create mode 100644 src/server/routes/telnyx.ts diff --git a/libs/gql-schema/dialer.ts b/libs/gql-schema/dialer.ts new file mode 100644 index 000000000..fbb0be71c --- /dev/null +++ b/libs/gql-schema/dialer.ts @@ -0,0 +1,55 @@ +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!]! + } + + type DialerQuestionResponseValue { + id: ID! + interactionStepId: ID! + question: String! + value: String! + } + + 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! + } +`; + +export default schema; diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 139c279ec..b9a5d8cbf 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,9 @@ const rootSchema = ` type RootQuery { currentUser: User organization(id:String!, utc:String): Organization + getNextDialerContact(assignmentId: String!): DialerCampaignContact + getDialerContact(dialerCampaignContactId: String!): DialerCampaignContact + callShiftAvailable(organizationId: String!): Boolean! campaign(id:String!): Campaign inviteByHash(hash:String!): [Invite] contact(id:String!): CampaignContact @@ -281,6 +285,11 @@ const rootSchema = ` type RootMutation { createInvite(invite:InviteInput!): Invite + initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! + updateDialerCall(dialerCallId: String!, status: String, telnyxCallControlId: String, answeredAt: String, endedAt: String): DialerCall! + saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! + markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! + requestCallShift(organizationId: String!): RequestCallShiftResult! createCampaign(campaign:CampaignInput!): Campaign createTemplateCampaign(organizationId: String!): Campaign! deleteTemplateCampaign(organizationId: String!, campaignId: String!): Boolean! @@ -425,7 +434,8 @@ export const schema = [ externalResponseOptionSchema, externalActivistCodeSchema, externalResultCodeSchema, - externalSyncConfigSchema + externalSyncConfigSchema, + dialerSchema ]; export default rootSchema; diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js index cd05cc510..4f98be364 100644 --- a/migrations/20260601000002_create-dialer-campaign-contact.js +++ b/migrations/20260601000002_create-dialer-campaign-contact.js @@ -37,13 +37,11 @@ exports.up = async function up(knex) { }); await knex.raw(` - -- Full index on campaign_id: archived contacts are still queried by campaign - -- for contact counts and overlap checks. + -- 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); + on dialer_campaign_contact (campaign_id) + where archived = false; - -- 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; @@ -54,7 +52,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..3f3d6ce70 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,8 +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/20260601000007_campaign-view-add-type.js b/migrations/20260601000007_campaign-view-add-type.js new file mode 100644 index 000000000..233d0d973 --- /dev/null +++ b/migrations/20260601000007_campaign-view-add-type.js @@ -0,0 +1,70 @@ +exports.up = async function up(knex) { + 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; + `); +}; + +exports.down = async function down(knex) { + 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 + from all_campaign + where is_template = false; + `); +}; 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/schema-dump.sql b/schema-dump.sql index 6f1c2258d..d012ae88f 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 ); @@ -4769,14 +4772,7 @@ 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); - - --- --- 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); +CREATE INDEX dialer_campaign_contact_campaign_id_idx ON public.dialer_campaign_contact USING btree (campaign_id) WHERE (archived = false); -- 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/schema.graphql b/src/schema.graphql index e14a7aea7..971dc91e4 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -212,6 +212,9 @@ type OptOutByCampaign { type RootQuery { currentUser: User organization(id:String!, utc:String): Organization + getNextDialerContact(assignmentId: String!): DialerCampaignContact + getDialerContact(dialerCampaignContactId: String!): DialerCampaignContact + callShiftAvailable(organizationId: String!): Boolean! campaign(id:String!): Campaign inviteByHash(hash:String!): [Invite] contact(id:String!): CampaignContact @@ -247,6 +250,11 @@ input SecondPassInput { type RootMutation { createInvite(invite:InviteInput!): Invite + initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! + updateDialerCall(dialerCallId: String!, status: String, telnyxCallControlId: String, answeredAt: String, endedAt: String): DialerCall! + saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! + markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! + requestCallShift(organizationId: String!): RequestCallShiftResult! createCampaign(campaign:CampaignInput!): Campaign createTemplateCampaign(organizationId: String!): Campaign! deleteTemplateCampaign(organizationId: String!, campaignId: String!): Boolean! @@ -1474,3 +1482,57 @@ 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!]! +} + +type DialerQuestionResponseValue { + id: ID! + interactionStepId: ID! + question: String! + value: String! +} + +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! +} diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 671bf8cf0..86dc2a0ba 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 @@ -492,7 +503,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 +580,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 +727,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..3f5a9ee7d --- /dev/null +++ b/src/server/api/dialer.ts @@ -0,0 +1,44 @@ +import { r } from "../models"; +import type { DialerContactWithData } from "./lib/dialer"; +import type { DialerCallRecord, DialerContactRecord } from "./types"; + +export const resolvers = { + DialerCampaignContact: { + id: (c: DialerContactRecord) => c.id, + campaignId: (c: DialerContactRecord) => c.campaign_id, + firstName: (c: DialerContactRecord) => c.first_name, + lastName: (c: DialerContactRecord) => c.last_name, + zip: (c: DialerContactRecord) => c.zip, + callStatus: (c: DialerContactWithData) => c.callStatus, + doNotCall: (c: DialerContactRecord) => c.do_not_call, + attemptCount: (c: DialerContactWithData) => c.attemptCount, + lastAttemptedAt: (c: DialerContactWithData) => c.lastAttemptedAt, + customFields: (c: DialerContactRecord) => c.custom_fields, + assignment: ( + c: DialerContactRecord, + _args: unknown, + { loaders }: { loaders: any } + ) => (c.assignment_id ? loaders.assignment.load(c.assignment_id) : null), + interactionSteps: (c: DialerContactWithData) => + c.interactionSteps ?? + r + .reader("interaction_step") + .where({ campaign_id: c.campaign_id, is_deleted: false }), + questionResponseValues: (c: DialerContactWithData) => + c.questionResponseValues ?? [], + tags: (c: DialerContactWithData) => c.tags ?? [] + }, + + DialerCall: { + id: (c: DialerCallRecord) => c.id, + dialerCampaignContactId: (c: DialerCallRecord) => + c.dialer_campaign_contact_id, + status: (c: DialerCallRecord) => c.status, + fromNumber: (c: DialerCallRecord) => c.from_number, + telnyxCallControlId: (c: DialerCallRecord) => c.telnyx_call_control_id, + createdAt: (c: DialerCallRecord) => c.created_at, + endedAt: (c: DialerCallRecord) => c.ended_at + } +}; + +export default resolvers; diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 20f0d54db..4982017d8 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,54 +609,82 @@ export const editCampaign = async ( ) { await accessRequired(user, organizationId, "ADMIN", /* superadmin */ true); - // Uploading contacts from a CSV invalidates external system configuration - // and invalidates filtered landlines - await r - .knex("campaign") - .update({ - external_system_id: null, - landlines_filtered: false - }) - .where({ id }); + // 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"; - 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 || null, + 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 { + // Uploading contacts from a CSV invalidates external system configuration + // and invalidates filtered landlines + await r + .knex("campaign") + .update({ + external_system_id: null, + landlines_filtered: false + }) + .where({ id }); + + 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..66ee224e7 --- /dev/null +++ b/src/server/api/lib/dialer.ts @@ -0,0 +1,463 @@ +import { ForbiddenError, UserInputError } from "apollo-server-errors"; + +import { config } from "../../../config"; +import { isNowBetween } from "../../../lib/timezones"; +import { r } from "../../models"; +import { OutsideTextingHoursError } from "../../send-message-errors"; +import type { + DialerCallRecord, + DialerContactRecord, + UserRecord +} from "../types"; +import { getNumberForDial } from "./assemble-numbers"; +import { getMessagingServiceById } from "./message-sending"; + +export interface DialerContactWithData extends DialerContactRecord { + callStatus: string; + attemptCount: number; + lastAttemptedAt: Date | null; + interactionSteps: unknown[]; + questionResponseValues: unknown[]; + tags: unknown[]; +} + +export const getContactWithData = async ( + contact: DialerContactRecord +): Promise => { + const [questionResponses, tags, interactionSteps, 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, + "dialer_question_response.is_deleted": false + }) + .select( + "dialer_question_response.id", + "dialer_question_response.interaction_step_id", + "dialer_question_response.value", + "istep.question as 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("interaction_step") + .where({ campaign_id: contact.campaign_id, is_deleted: false }), + 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, + interactionSteps, + tags, + questionResponseValues: questionResponses.map((qr) => ({ + id: qr.id, + interactionStepId: qr.interaction_step_id, + question: qr.istep_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."); + + if (!user.is_superadmin && contact.assignment_id) { + const assignment = await r + .reader("assignment") + .where({ id: contact.assignment_id, user_id: user.id }) + .first(); + if (!assignment) { + throw new ForbiddenError( + "You are not authorized to access that contact." + ); + } + } + + return contact; +}; + +export const getNextDialerContact = async ( + assignmentId: string +): Promise => { + const assignment = await r + .reader("assignment") + .where({ id: assignmentId }) + .first("campaign_id"); + + if (!assignment) return null; + + const campaign = await r + .reader("all_campaign") + .where({ id: assignment.campaign_id }) + .first(); + + if (!campaign) return null; + + const contact: DialerContactRecord | undefined = await r + .reader("dialer_campaign_contact") + // 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. + .where({ + assignment_id: assignmentId, + do_not_call: false, + archived: false + }) + .whereIn("call_status", ["not_attempted", "no_answer"]) + // Only serve contacts callable now under the campaign's contact hours + // (same rule as texting). + .whereRaw("contact_is_textable_now(coalesce(timezone, ?), ?, ?, true)", [ + campaign.timezone, + campaign.texting_hours_start, + campaign.texting_hours_end + ]) + .orderBy("id", "asc") + .first(); + + 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 `all_campaign` in the +// outer query) has at least one unclaimed, callable-now contact. +const whereHasUnclaimedCallableContact = (builder: any) => { + builder + .select(r.reader.raw(1)) + .from("dialer_campaign_contact as dcc") + .whereRaw("dcc.campaign_id = all_campaign.id") + .whereNull("dcc.assignment_id") + .where("dcc.do_not_call", false) + .where("dcc.archived", false) + .whereIn("dcc.call_status", ["not_attempted", "no_answer"]) + .whereRaw( + "contact_is_textable_now(coalesce(dcc.timezone, all_campaign.timezone), all_campaign.texting_hours_start, all_campaign.texting_hours_end, true)" + ); +}; + +// 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 callShiftsAvailable = async ( + organizationId: string +): Promise => { + const campaign = await r + .reader("all_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("all_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("*"); + } + + 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 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 = ?, updated_at = now() + from claimed + where dcc.id = claimed.id + returning dcc.id; + `, + [ + campaign.id, + 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" + ) as any + ) + .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); +}; diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index 49d2f2e20..9d04cb0cf 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -62,6 +62,13 @@ import { markAutosendingPaused, unqueueAutosending } from "./lib/campaign"; +import { + assignDialerShift, + initiateCall, + markDialerContactComplete, + saveDialerQuestionResponses, + updateDialerCall +} from "./lib/dialer"; import { getSecondPassCampaign } from "./lib/mark-second-pass"; import { saveNewIncomingMessage } from "./lib/message-sending"; import { processNumbers } from "./lib/opt-out"; @@ -3331,6 +3338,85 @@ 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, + status, + telnyxCallControlId, + answeredAt, + endedAt + }: { + dialerCallId: string; + status?: string; + telnyxCallControlId?: string; + answeredAt?: string; + endedAt?: string; + }, + { user }: SpokeRequestContext + ) => { + return updateDialerCall(dialerCallId, user, { + status, + telnyxCallControlId, + answeredAt, + endedAt + }); + }, + + 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 + ); } } }; diff --git a/src/server/api/root-resolvers.ts b/src/server/api/root-resolvers.ts index 39787b86f..ef334064b 100644 --- a/src/server/api/root-resolvers.ts +++ b/src/server/api/root-resolvers.ts @@ -12,8 +12,18 @@ 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 { + callShiftsAvailable, + getDialerContact, + getNextDialerContact +} from "./lib/dialer"; import { formatPage } from "./lib/pagination"; import { getUsers, getUsersById } from "./user"; @@ -524,6 +534,28 @@ 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); + }, + + callShiftAvailable: async ( + _root, + { organizationId }: { organizationId: string }, + { user } + ) => { + await accessRequired(user, organizationId, "TEXTER"); + return callShiftsAvailable(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..f6f7f58ec 100644 --- a/src/server/api/user.js +++ b/src/server/api/user.js @@ -320,9 +320,54 @@ 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("all_campaign", "all_campaign.id", "assignment.campaign_id") + .where({ + "assignment.user_id": user.id, + "all_campaign.organization_id": organizationId, + "all_campaign.type": "call", + "all_campaign.is_started": true, + "all_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") + .where("dialer_campaign_contact.do_not_call", false) + .where("dialer_campaign_contact.archived", false) + .whereIn("dialer_campaign_contact.call_status", [ + "not_attempted", + "no_answer" + ]) + // Calling follows the same contact hours as texting: don't surface + // the todo when nobody is callable in their timezone right now. + .whereRaw( + "contact_is_textable_now(coalesce(dialer_campaign_contact.timezone, all_campaign.timezone), all_campaign.texting_hours_start, all_campaign.texting_hours_end, true)" + ); + }) + .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/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/campaign-builder/import-contact-csv-from-url.ts b/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts index 0bb12c033..147a0fbd9 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 all_campaign where id = $1`, + [campaignId] + ); + const isCallCampaign = campaign?.type === "call"; + const { rows: [{ id: jobId }] } = await client.query<{ id: string }>( @@ -163,13 +176,20 @@ export const importContactCsvFromUrl: Task = async ( ); await withTransaction(client, async (trx) => { - 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( + `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 + ]); + } const accumulator: CampaignContactInsertRow[] = []; for await (const row of csvStream) { @@ -181,16 +201,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") From 2929c8888b33c69e52b8086e18fa27d3dc2f096e Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Sun, 14 Jun 2026 07:18:08 -0700 Subject: [PATCH 2/3] chore(dialer): address feedback --- libs/gql-schema/dialer.ts | 7 + libs/gql-schema/schema.ts | 2 +- ...01000002_create-dialer-campaign-contact.js | 8 +- ...0005_create-dialer-campaign-contact-tag.js | 1 + .../20260601000007_campaign-view-add-type.js | 70 -------- schema-dump.sql | 2 +- src/schema.graphql | 9 +- src/server/api/dialer.ts | 52 +++--- src/server/api/lib/campaign.ts | 23 +-- src/server/api/lib/dialer.ts | 156 ++++++++++-------- src/server/api/root-mutations.ts | 22 +-- src/server/api/root-resolvers.ts | 4 +- src/server/api/user.js | 30 ++-- src/server/models/index.ts | 30 ++++ .../import-contact-csv-from-url.ts | 13 +- 15 files changed, 215 insertions(+), 214 deletions(-) delete mode 100644 migrations/20260601000007_campaign-view-add-type.js diff --git a/libs/gql-schema/dialer.ts b/libs/gql-schema/dialer.ts index fbb0be71c..f7171d6ca 100644 --- a/libs/gql-schema/dialer.ts +++ b/libs/gql-schema/dialer.ts @@ -50,6 +50,13 @@ export const schema = ` 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 b9a5d8cbf..8d9acfe88 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -286,7 +286,7 @@ const rootSchema = ` type RootMutation { createInvite(invite:InviteInput!): Invite initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! - updateDialerCall(dialerCallId: String!, status: String, telnyxCallControlId: String, answeredAt: String, endedAt: String): DialerCall! + updateDialerCall(dialerCallId: String!, input: UpdateDialerCallInput!): DialerCall! saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! requestCallShift(organizationId: String!): RequestCallShiftResult! diff --git a/migrations/20260601000002_create-dialer-campaign-contact.js b/migrations/20260601000002_create-dialer-campaign-contact.js index 4f98be364..a7568c00f 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; diff --git a/migrations/20260601000005_create-dialer-campaign-contact-tag.js b/migrations/20260601000005_create-dialer-campaign-contact-tag.js index 3f3d6ce70..fde41bb7c 100644 --- a/migrations/20260601000005_create-dialer-campaign-contact-tag.js +++ b/migrations/20260601000005_create-dialer-campaign-contact-tag.js @@ -39,6 +39,7 @@ 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_tag_id_idx; `); return knex.schema.dropTable("dialer_campaign_contact_tag"); }; diff --git a/migrations/20260601000007_campaign-view-add-type.js b/migrations/20260601000007_campaign-view-add-type.js deleted file mode 100644 index 233d0d973..000000000 --- a/migrations/20260601000007_campaign-view-add-type.js +++ /dev/null @@ -1,70 +0,0 @@ -exports.up = async function up(knex) { - 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; - `); -}; - -exports.down = async function down(knex) { - 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 - from all_campaign - where is_template = false; - `); -}; diff --git a/schema-dump.sql b/schema-dump.sql index d012ae88f..b78331a34 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -4772,7 +4772,7 @@ 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); -- diff --git a/src/schema.graphql b/src/schema.graphql index 971dc91e4..54c1b9968 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -251,7 +251,7 @@ input SecondPassInput { type RootMutation { createInvite(invite:InviteInput!): Invite initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult! - updateDialerCall(dialerCallId: String!, status: String, telnyxCallControlId: String, answeredAt: String, endedAt: String): DialerCall! + updateDialerCall(dialerCallId: String!, input: UpdateDialerCallInput!): DialerCall! saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact! markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact! requestCallShift(organizationId: String!): RequestCallShiftResult! @@ -1536,3 +1536,10 @@ input DialerQuestionResponseInput { interactionStepId: String! value: String! } + +input UpdateDialerCallInput { + status: String + telnyxCallControlId: String + answeredAt: String + endedAt: String +} diff --git a/src/server/api/dialer.ts b/src/server/api/dialer.ts index 3f5a9ee7d..2d91c63c0 100644 --- a/src/server/api/dialer.ts +++ b/src/server/api/dialer.ts @@ -1,43 +1,51 @@ -import { r } from "../models"; import type { DialerContactWithData } from "./lib/dialer"; -import type { DialerCallRecord, DialerContactRecord } from "./types"; +import { sqlResolvers } from "./lib/utils"; +import type { DialerContactRecord } from "./types"; export const resolvers = { DialerCampaignContact: { - id: (c: DialerContactRecord) => c.id, - campaignId: (c: DialerContactRecord) => c.campaign_id, - firstName: (c: DialerContactRecord) => c.first_name, - lastName: (c: DialerContactRecord) => c.last_name, - zip: (c: DialerContactRecord) => c.zip, + ...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, - doNotCall: (c: DialerContactRecord) => c.do_not_call, attemptCount: (c: DialerContactWithData) => c.attemptCount, lastAttemptedAt: (c: DialerContactWithData) => c.lastAttemptedAt, - customFields: (c: DialerContactRecord) => c.custom_fields, assignment: ( c: DialerContactRecord, _args: unknown, { loaders }: { loaders: any } ) => (c.assignment_id ? loaders.assignment.load(c.assignment_id) : null), - interactionSteps: (c: DialerContactWithData) => - c.interactionSteps ?? - r - .reader("interaction_step") - .where({ campaign_id: c.campaign_id, is_deleted: false }), + // 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 ?? [] }, DialerCall: { - id: (c: DialerCallRecord) => c.id, - dialerCampaignContactId: (c: DialerCallRecord) => - c.dialer_campaign_contact_id, - status: (c: DialerCallRecord) => c.status, - fromNumber: (c: DialerCallRecord) => c.from_number, - telnyxCallControlId: (c: DialerCallRecord) => c.telnyx_call_control_id, - createdAt: (c: DialerCallRecord) => c.created_at, - endedAt: (c: DialerCallRecord) => c.ended_at + ...sqlResolvers([ + "id", + "dialerCampaignContactId", + "status", + "fromNumber", + "telnyxCallControlId", + "createdAt", + "answeredAt", + "endedAt" + ]) } }; diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 4982017d8..4e4622bdd 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -613,6 +613,17 @@ export const editCampaign = async ( // 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. Reset for both campaign types (at + // least until filter-landlines is dropped). + await r + .knex("campaign") + .update({ + external_system_id: null, + landlines_filtered: false + }) + .where({ id }); + if (isCallCampaign) { // Call campaigns store contacts in dialer_campaign_contact and are // dialed by volunteers over WebRTC; they never enter the SMS @@ -624,7 +635,7 @@ export const editCampaign = async ( last_name: datum.lastName, cell: datum.cell, external_id: datum.external_id || null, - zip: datum.zip || null, + zip: datum.zip || "", timezone: datum.zip ? zipCodeToTimeZone.lookup(datum.zip) : null, custom_fields: JSON.stringify(datum.customFields ?? {}) })); @@ -636,16 +647,6 @@ export const editCampaign = async ( await trx.batchInsert("dialer_campaign_contact", dialerContacts, 1000); }); } else { - // Uploading contacts from a CSV invalidates external system configuration - // and invalidates filtered landlines - await r - .knex("campaign") - .update({ - external_system_id: null, - landlines_filtered: false - }) - .where({ id }); - const contactsToSave = campaign.contacts.map((datum) => { const modelData = { campaign_id: id, diff --git a/src/server/api/lib/dialer.ts b/src/server/api/lib/dialer.ts index 66ee224e7..ce9e30ab7 100644 --- a/src/server/api/lib/dialer.ts +++ b/src/server/api/lib/dialer.ts @@ -1,4 +1,5 @@ import { ForbiddenError, UserInputError } from "apollo-server-errors"; +import type { Knex } from "knex"; import { config } from "../../../config"; import { isNowBetween } from "../../../lib/timezones"; @@ -7,24 +8,65 @@ 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; - interactionSteps: unknown[]; - questionResponseValues: unknown[]; - tags: unknown[]; + questionResponseValues: DialerQuestionResponseValue[]; + tags: TagRecord[]; } export const getContactWithData = async ( contact: DialerContactRecord ): Promise => { - const [questionResponses, tags, interactionSteps, calls] = await Promise.all([ + // 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( @@ -33,14 +75,13 @@ export const getContactWithData = async ( "istep.id" ) .where({ - "dialer_question_response.dialer_campaign_contact_id": contact.id, - "dialer_question_response.is_deleted": false + "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 as istep_question" + "istep.question" ), r .reader("dialer_campaign_contact_tag") @@ -49,9 +90,6 @@ export const getContactWithData = async ( "dialer_campaign_contact_tag.dialer_campaign_contact_id": contact.id }) .select("tag.*"), - r - .reader("interaction_step") - .where({ campaign_id: contact.campaign_id, is_deleted: false }), r .reader("dialer_call") .where({ dialer_campaign_contact_id: contact.id }) @@ -63,12 +101,11 @@ export const getContactWithData = async ( callStatus: calls[0]?.status ?? "NOT_ATTEMPTED", attemptCount: calls.length, lastAttemptedAt: calls[0]?.created_at ?? null, - interactionSteps, tags, questionResponseValues: questionResponses.map((qr) => ({ id: qr.id, interactionStepId: qr.interaction_step_id, - question: qr.istep_question, + question: qr.question, value: qr.value })) }; @@ -85,11 +122,15 @@ const assertContactAccess = async ( if (!contact) throw new UserInputError("Dialer contact not found."); - if (!user.is_superadmin && contact.assignment_id) { - const assignment = await r - .reader("assignment") - .where({ id: contact.assignment_id, user_id: user.id }) - .first(); + // 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." @@ -103,40 +144,21 @@ const assertContactAccess = async ( export const getNextDialerContact = async ( assignmentId: string ): Promise => { - const assignment = await r - .reader("assignment") - .where({ id: assignmentId }) - .first("campaign_id"); - - if (!assignment) return null; - - const campaign = await r - .reader("all_campaign") - .where({ id: assignment.campaign_id }) - .first(); - - if (!campaign) return null; - - const contact: DialerContactRecord | undefined = await r - .reader("dialer_campaign_contact") - // 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. - .where({ - assignment_id: assignmentId, - do_not_call: false, - archived: false - }) - .whereIn("call_status", ["not_attempted", "no_answer"]) - // Only serve contacts callable now under the campaign's contact hours - // (same rule as texting). - .whereRaw("contact_is_textable_now(coalesce(timezone, ?), ?, ?, true)", [ - campaign.timezone, - campaign.texting_hours_start, - campaign.texting_hours_end - ]) - .orderBy("id", "asc") - .first(); + // 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); @@ -150,29 +172,24 @@ export const getDialerContact = async ( return getContactWithData(contact); }; -// Correlated EXISTS condition: the campaign (aliased `all_campaign` in the -// outer query) has at least one unclaimed, callable-now contact. -const whereHasUnclaimedCallableContact = (builder: any) => { +// 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 = all_campaign.id") - .whereNull("dcc.assignment_id") - .where("dcc.do_not_call", false) - .where("dcc.archived", false) - .whereIn("dcc.call_status", ["not_attempted", "no_answer"]) - .whereRaw( - "contact_is_textable_now(coalesce(dcc.timezone, all_campaign.timezone), all_campaign.texting_hours_start, all_campaign.texting_hours_end, true)" - ); + .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 callShiftsAvailable = async ( +export const callShiftAvailable = async ( organizationId: string ): Promise => { const campaign = await r - .reader("all_campaign") + .reader("campaign") .where({ organization_id: organizationId, type: "call", @@ -201,7 +218,7 @@ export const assignDialerShift = async ( count: number; }> => { return parentTrx.transaction(async (trx) => { - const campaign = await trx("all_campaign") + const campaign = await trx("campaign") .where({ organization_id: organizationId, type: "call", @@ -227,6 +244,9 @@ export const assignDialerShift = async ( .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 ( @@ -237,19 +257,21 @@ export const assignDialerShift = async ( 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 = ?, updated_at = now() + 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, @@ -432,7 +454,7 @@ export const saveDialerQuestionResponses = async ( .onConflict( r.knex.raw( "(interaction_step_id, dialer_campaign_contact_id) WHERE is_deleted = false" - ) as any + ) ) .merge({ value: qr.value, updated_at: new Date() }); } diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index 9d04cb0cf..039c74e55 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -3365,25 +3365,19 @@ const rootMutations = { _root, { dialerCallId, - status, - telnyxCallControlId, - answeredAt, - endedAt + input }: { dialerCallId: string; - status?: string; - telnyxCallControlId?: string; - answeredAt?: string; - endedAt?: string; + input: { + status?: string; + telnyxCallControlId?: string; + answeredAt?: string; + endedAt?: string; + }; }, { user }: SpokeRequestContext ) => { - return updateDialerCall(dialerCallId, user, { - status, - telnyxCallControlId, - answeredAt, - endedAt - }); + return updateDialerCall(dialerCallId, user, input); }, saveDialerQuestionResponses: async ( diff --git a/src/server/api/root-resolvers.ts b/src/server/api/root-resolvers.ts index ef334064b..114a4a505 100644 --- a/src/server/api/root-resolvers.ts +++ b/src/server/api/root-resolvers.ts @@ -20,7 +20,7 @@ import { } from "./errors"; import { getStepsToUpdate } from "./lib/bulk-script-editor"; import { - callShiftsAvailable, + callShiftAvailable as callShiftAvailableLib, getDialerContact, getNextDialerContact } from "./lib/dialer"; @@ -553,7 +553,7 @@ const rootResolvers = { { user } ) => { await accessRequired(user, organizationId, "TEXTER"); - return callShiftsAvailable(organizationId); + return callShiftAvailableLib(organizationId); }, isValidAttachment: async (_root, { fileUrl }, _context) => { diff --git a/src/server/api/user.js b/src/server/api/user.js index f6f7f58ec..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"; @@ -329,30 +330,25 @@ export const resolvers = { // shouldn't appear. const callAssignmentIds = await r .reader("assignment") - .join("all_campaign", "all_campaign.id", "assignment.campaign_id") + .join("campaign", "campaign.id", "assignment.campaign_id") .where({ "assignment.user_id": user.id, - "all_campaign.organization_id": organizationId, - "all_campaign.type": "call", - "all_campaign.is_started": true, - "all_campaign.is_archived": false + "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") - .where("dialer_campaign_contact.do_not_call", false) - .where("dialer_campaign_contact.archived", false) - .whereIn("dialer_campaign_contact.call_status", [ - "not_attempted", - "no_answer" - ]) - // Calling follows the same contact hours as texting: don't surface - // the todo when nobody is callable in their timezone right now. - .whereRaw( - "contact_is_textable_now(coalesce(dialer_campaign_contact.timezone, all_campaign.timezone), all_campaign.texting_hours_start, all_campaign.texting_hours_end, true)" - ); + .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"); 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/tasks/campaign-builder/import-contact-csv-from-url.ts b/src/server/tasks/campaign-builder/import-contact-csv-from-url.ts index 147a0fbd9..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 @@ -146,7 +146,7 @@ export const importContactCsvFromUrl: Task = async ( const { rows: [campaign] } = await client.query<{ type: string }>( - `select type from all_campaign where id = $1`, + `select type from campaign where id = $1`, [campaignId] ); const isCallCampaign = campaign?.type === "call"; @@ -176,16 +176,19 @@ 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] + ); if (isCallCampaign) { await trx.query( `delete from dialer_campaign_contact where campaign_id = $1`, [campaignId] ); } else { - 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 ]); From f14a0173db939cdfbeaa18d24b5f39b288b6187a Mon Sep 17 00:00:00 2001 From: Sukhada Kulkarni Date: Tue, 16 Jun 2026 08:30:48 -0700 Subject: [PATCH 3/3] 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 --- .tool-versions | 2 +- libs/gql-schema/dialer.ts | 12 + libs/gql-schema/schema.ts | 2 + .../src/graphql/campaign-list.graphql | 1 + .../src/graphql/campaign-stats.graphql | 1 + libs/spoke-codegen/src/graphql/dialer.graphql | 166 ++++ package.json | 3 +- seeds/dev.js | 11 +- seeds/staging.js | 17 +- .../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 | 14 + src/server/api/assignment.js | 18 + src/server/api/campaign.js | 8 +- src/server/api/dialer.ts | 9 +- src/server/api/lib/dialer.ts | 116 +++ src/server/api/root-mutations.ts | 82 +- src/server/api/root-resolvers.ts | 9 + src/server/tasks/assign-texters.ts | 99 ++- yarn.lock | 24 +- 38 files changed, 2733 insertions(+), 213 deletions(-) create mode 100644 libs/spoke-codegen/src/graphql/dialer.graphql 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 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 index f7171d6ca..2c32a8c16 100644 --- a/libs/gql-schema/dialer.ts +++ b/libs/gql-schema/dialer.ts @@ -14,6 +14,7 @@ export const schema = ` interactionSteps: [InteractionStep!]! questionResponseValues: [DialerQuestionResponseValue!]! tags: [Tag!]! + campaignVariables: [CampaignVariable!]! } type DialerQuestionResponseValue { @@ -23,6 +24,17 @@ export const schema = ` 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! diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 8d9acfe88..da0727b35 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -249,6 +249,7 @@ const rootSchema = ` 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] @@ -289,6 +290,7 @@ const rootSchema = ` 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! 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/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/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/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 && ( <> + ) : ( + <> + {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 54c1b9968..3c74b45c4 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -214,6 +214,7 @@ type RootQuery { 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] @@ -254,6 +255,7 @@ type RootMutation { 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! @@ -1500,6 +1502,7 @@ type DialerCampaignContact { interactionSteps: [InteractionStep!]! questionResponseValues: [DialerQuestionResponseValue!]! tags: [Tag!]! + campaignVariables: [CampaignVariable!]! } type DialerQuestionResponseValue { @@ -1509,6 +1512,17 @@ type DialerQuestionResponseValue { 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! 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 86dc2a0ba..ade8e9798 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -398,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), diff --git a/src/server/api/dialer.ts b/src/server/api/dialer.ts index 2d91c63c0..c2a0d0d00 100644 --- a/src/server/api/dialer.ts +++ b/src/server/api/dialer.ts @@ -1,3 +1,4 @@ +import { r } from "../models"; import type { DialerContactWithData } from "./lib/dialer"; import { sqlResolvers } from "./lib/utils"; import type { DialerContactRecord } from "./types"; @@ -32,7 +33,13 @@ export const resolvers = { ) => loaders.interactionStepsByCampaign.load(c.campaign_id), questionResponseValues: (c: DialerContactWithData) => c.questionResponseValues ?? [], - tags: (c: DialerContactWithData) => c.tags ?? [] + tags: (c: DialerContactWithData) => c.tags ?? [], + campaignVariables: (c: DialerContactRecord) => + r + .reader("campaign_variable") + .where({ campaign_id: c.campaign_id }) + .whereNull("deleted_at") + .select("*") }, DialerCall: { diff --git a/src/server/api/lib/dialer.ts b/src/server/api/lib/dialer.ts index ce9e30ab7..c52f423d2 100644 --- a/src/server/api/lib/dialer.ts +++ b/src/server/api/lib/dialer.ts @@ -2,6 +2,7 @@ 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"; @@ -483,3 +484,118 @@ export const markDialerContactComplete = async ( 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 039c74e55..72457ae65 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -67,6 +67,7 @@ import { initiateCall, markDialerContactComplete, saveDialerQuestionResponses, + tagDialerContact, updateDialerCall } from "./lib/dialer"; import { getSecondPassCampaign } from "./lib/mark-second-pass"; @@ -1864,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 @@ -2210,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": @@ -2230,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); @@ -3411,6 +3462,25 @@ const rootMutations = { 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 114a4a505..a871f1ad5 100644 --- a/src/server/api/root-resolvers.ts +++ b/src/server/api/root-resolvers.ts @@ -22,6 +22,7 @@ import { getStepsToUpdate } from "./lib/bulk-script-editor"; import { callShiftAvailable as callShiftAvailableLib, getDialerContact, + getDialerContactTextingHistory, getNextDialerContact } from "./lib/dialer"; import { formatPage } from "./lib/pagination"; @@ -547,6 +548,14 @@ const rootResolvers = { return getDialerContact(dialerCampaignContactId, user); }, + dialerContactTextingHistory: async ( + _root, + { dialerCampaignContactId }: { dialerCampaignContactId: string }, + { user } + ) => { + return getDialerContactTextingHistory(dialerCampaignContactId, user); + }, + callShiftAvailable: async ( _root, { organizationId }: { organizationId: string }, 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/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==