Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
nodejs 16.14.0
nodejs 20.16.0
yarn 1.22.19
74 changes: 74 additions & 0 deletions libs/gql-schema/dialer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export const schema = `
type DialerCampaignContact {
id: ID!
campaignId: ID!
firstName: String!
lastName: String!
zip: String
callStatus: String!
doNotCall: Boolean!
attemptCount: Int!
lastAttemptedAt: Date
customFields: JSON!
assignment: Assignment
interactionSteps: [InteractionStep!]!
questionResponseValues: [DialerQuestionResponseValue!]!
tags: [Tag!]!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: I think this should be a new DialerCampagnContactTag type which includes tagger to indicate who tagged the contact like CampaignContactTag does

campaignVariables: [CampaignVariable!]!
}

type DialerQuestionResponseValue {
id: ID!
interactionStepId: ID!
question: String!
value: String!
}

# A past texting conversation with the same person (matched by phone), shown
# as context on the calling screen. One entry per prior campaign_contact.
type DialerContactConversation {
campaignId: ID!
campaignTitle: String!
contactId: ID!
firstName: String
lastName: String
messages: [Message!]!
}

type DialerCall {
id: ID!
dialerCampaignContactId: ID!
status: String!
fromNumber: String
telnyxCallControlId: String
createdAt: Date!
answeredAt: Date
endedAt: Date
}

type InitiateCallResult {
dialerCallId: ID!
contactPhone: String!
fromNumber: String!
}

type RequestCallShiftResult {
assignmentId: ID
campaignId: ID
count: Int!
}

input DialerQuestionResponseInput {
interactionStepId: String!
value: String!
}

input UpdateDialerCallInput {
status: String
telnyxCallControlId: String
answeredAt: String
endedAt: String
}
`;

export default schema;
14 changes: 13 additions & 1 deletion libs/gql-schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -246,6 +247,10 @@ const rootSchema = `
type RootQuery {
currentUser: User
organization(id:String!, utc:String): Organization
getNextDialerContact(assignmentId: String!): DialerCampaignContact
getDialerContact(dialerCampaignContactId: String!): DialerCampaignContact
dialerContactTextingHistory(dialerCampaignContactId: String!): [DialerContactConversation!]!
callShiftAvailable(organizationId: String!): Boolean!
campaign(id:String!): Campaign
inviteByHash(hash:String!): [Invite]
contact(id:String!): CampaignContact
Expand Down Expand Up @@ -281,6 +286,12 @@ const rootSchema = `

type RootMutation {
createInvite(invite:InviteInput!): Invite
initiateCall(assignmentId: String!, dialerCampaignContactId: String!): InitiateCallResult!
updateDialerCall(dialerCallId: String!, input: UpdateDialerCallInput!): DialerCall!
saveDialerQuestionResponses(dialerCampaignContactId: String!, questionResponses: [DialerQuestionResponseInput!]!): DialerCampaignContact!
markDialerContactComplete(dialerCampaignContactId: String!, callStatus: String!): DialerCampaignContact!
tagDialerContact(dialerCampaignContactId: String!, tag: ContactTagActionInput!): DialerCampaignContact!
requestCallShift(organizationId: String!): RequestCallShiftResult!
createCampaign(campaign:CampaignInput!): Campaign
createTemplateCampaign(organizationId: String!): Campaign!
deleteTemplateCampaign(organizationId: String!, campaignId: String!): Boolean!
Expand Down Expand Up @@ -425,7 +436,8 @@ export const schema = [
externalResponseOptionSchema,
externalActivistCodeSchema,
externalResultCodeSchema,
externalSyncConfigSchema
externalSyncConfigSchema,
dialerSchema
];

export default rootSchema;
1 change: 1 addition & 0 deletions libs/spoke-codegen/src/graphql/campaign-list.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
fragment CampaignListEntry on Campaign {
id
title
campaignType
isStarted
isApproved
isArchived
Expand Down
1 change: 1 addition & 0 deletions libs/spoke-codegen/src/graphql/campaign-stats.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ query getCampaign($campaignId: String!) {
campaign(id: $campaignId) {
id
title
campaignType
dueBy
isArchived
isStarted
Expand Down
166 changes: 166 additions & 0 deletions libs/spoke-codegen/src/graphql/dialer.graphql
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ exports.up = async function up(knex) {
on dialer_campaign_contact (campaign_id, assignment_id, do_not_call)
where archived = false;

-- Each contact (identified by cell) should only appear once per campaign (mirrors campaign_contact's cell+campaign_id unique constraint).
-- One phone number per campaign (mirrors campaign_contact's cell+campaign_id unique constraint).
alter table dialer_campaign_contact
add constraint dialer_campaign_contact_cell_campaign_id_unique unique (cell, campaign_id);

Expand Down
3 changes: 1 addition & 2 deletions migrations/20260601000003_create-dialer-call.js
Original file line number Diff line number Diff line change
@@ -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<void> }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ exports.up = async function up(knex) {
});

await knex.raw(`
create index dialer_campaign_contact_tag_contact_idx
Comment thread
sukhada marked this conversation as resolved.
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);

Expand All @@ -42,7 +39,6 @@ exports.up = async function up(knex) {
exports.down = async function down(knex) {
await knex.raw(`
drop trigger if exists _500_dialer_campaign_contact_tag_updated_at on dialer_campaign_contact_tag;
drop index if exists dialer_campaign_contact_tag_contact_idx;
drop index if exists dialer_campaign_contact_tag_tag_id_idx;
Comment thread
sukhada marked this conversation as resolved.
`);
return knex.schema.dropTable("dialer_campaign_contact_tag");
Expand Down
23 changes: 23 additions & 0 deletions migrations/20260601000008_dialer-call-add-timing.js
Original file line number Diff line number Diff line change
@@ -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<void> }
*/
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<void> }
*/
exports.down = async function down(knex) {
await knex.schema.alterTable("dialer_call", (table) => {
table.dropColumn("answered_at");
});
};
26 changes: 26 additions & 0 deletions migrations/20260601000009_call-campaigns-allow-autoassign.js
Original file line number Diff line number Diff line change
@@ -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<void> }
*/
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<void> }
*/
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);
`);
};
Loading
Loading