diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index 68b972c01..ace578ecf 100644 --- a/__test__/testbed-preparation/core.ts +++ b/__test__/testbed-preparation/core.ts @@ -140,7 +140,6 @@ export type CreateCampaignOptions = Partial< | "timezone" | "isAutoassignEnabled" | "isAssignmentLimitedToTeams" - | "landlinesFiltered" > & { creatorId: number; repliesStaleAfterMinutes: number; @@ -176,11 +175,10 @@ export const createCampaign = async ( is_autoassign_enabled, limit_assignment_to_teams, replies_stale_after_minutes, - landlines_filtered, external_system_id, autosend_status, autosend_user_id - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) returning * `, [ @@ -200,7 +198,6 @@ export const createCampaign = async ( options.isAutoassignEnabled ?? false, options.isAssignmentLimitedToTeams ?? false, options.repliesStaleAfterMinutes ?? null, - options.landlinesFiltered ?? false, options.externalSystemId ?? null, options.autosendStatus ?? null, options.autosendUserId ?? null @@ -233,12 +230,11 @@ export const createTemplate = async ( is_autoassign_enabled, limit_assignment_to_teams, replies_stale_after_minutes, - landlines_filtered, external_system_id, autosend_status, autosend_user_id, is_template - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) returning * `, [ @@ -258,7 +254,6 @@ export const createTemplate = async ( options.isAutoassignEnabled ?? false, options.isAssignmentLimitedToTeams ?? false, options.repliesStaleAfterMinutes ?? null, - options.landlinesFiltered ?? false, options.externalSystemId ?? null, options.autosendStatus ?? null, options.autosendUserId ?? null, diff --git a/dev-tools/clone_campaign_sproc.sql b/dev-tools/clone_campaign_sproc.sql index f269ed7d4..f6010391a 100644 --- a/dev-tools/clone_campaign_sproc.sql +++ b/dev-tools/clone_campaign_sproc.sql @@ -167,7 +167,6 @@ cross join lateral graphile_worker.add_job( 'campaignId', new_campaigns.campaign_id, 'offset': new_campaigns.offset, 'limit': new_campaigns.limit, - 'initiateFilterLandlines', true, 'signedDownloadUrl', 'https://s3.aws.amazon.com/my-secure-bucket/ak-export.csv', 'columnMapping': json_build_object( 'firstName', 'my_first_name_col_name', diff --git a/libs/gql-schema/campaign.ts b/libs/gql-schema/campaign.ts index c261cd75a..225e75a6f 100644 --- a/libs/gql-schema/campaign.ts +++ b/libs/gql-schema/campaign.ts @@ -117,7 +117,6 @@ export const schema = ` timezone: String createdAt: String! previewUrl: String - landlinesFiltered: Boolean! externalSystem: ExternalSystem syncReadiness: ExternalSyncReadinessState! externalSyncConfigurations(after: Cursor, first: Int): ExternalSyncQuestionResponseConfigPage! @@ -182,7 +181,6 @@ export const schema = ` contacts: [CampaignContactInput!] contactsFile: Upload externalListId: String - filterOutLandlines: Boolean excludeCampaignIds: [String!] contactSql: String organizationId: String diff --git a/libs/gql-schema/organization-settings.ts b/libs/gql-schema/organization-settings.ts index 326e4b2d4..8507abaab 100644 --- a/libs/gql-schema/organization-settings.ts +++ b/libs/gql-schema/organization-settings.ts @@ -6,7 +6,6 @@ export const schema = ` showContactCell: Boolean confirmationClickForScriptLinks: Boolean defaulTexterApprovalStatus: RequestAutoApprove - numbersApiKey: String trollbotWebhookUrl: String scriptPreviewForSupervolunteers: Boolean defaultCampaignBuilderMode: CampaignBuilderMode @@ -41,7 +40,6 @@ export const schema = ` # Owner defaulTexterApprovalStatus: RequestAutoApprove - numbersApiKey: String trollbotWebhookUrl: String } `; diff --git a/libs/gql-schema/organization.ts b/libs/gql-schema/organization.ts index 9b1de00cd..678cc793f 100644 --- a/libs/gql-schema/organization.ts +++ b/libs/gql-schema/organization.ts @@ -47,7 +47,6 @@ export const schema = ` escalatedConversationCount: Int! linkDomains: [LinkDomain]! unhealthyLinkDomains: [UnhealthyLinkDomain]! - numbersApiKey: String settings: OrganizationSettings! tagList: [Tag!]! escalationTagList: [Tag!]! diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 139c279ec..fe731f6b4 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -288,7 +288,6 @@ const rootSchema = ` editCampaign(id:String!, campaign:CampaignInput!): Campaign saveCampaignGroups(organizationId: String!, campaignGroups: [CampaignGroupInput!]!): [CampaignGroup!]! deleteCampaignGroup(organizationId: String!, campaignGroupId: String!): Boolean! - filterLandlines(id:String!): Campaign bulkUpdateScript(organizationId:String!, findAndReplace: BulkUpdateScriptInput!): [ScriptUpdateResult] deleteJob(campaignId:String!, id:String!): JobRequest copyCampaign(id: String!): Campaign diff --git a/libs/spoke-codegen/src/graphql/campaign-stats.graphql b/libs/spoke-codegen/src/graphql/campaign-stats.graphql index eef9e5919..4c4ec8b83 100644 --- a/libs/spoke-codegen/src/graphql/campaign-stats.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-stats.graphql @@ -50,7 +50,6 @@ query getOrganizationData($organizationId: String!) { lastName displayName } - numbersApiKey campaigns(cursor: { offset: 0, limit: 5000 }) { campaigns { id diff --git a/libs/spoke-codegen/src/graphql/spoke-context.graphql b/libs/spoke-codegen/src/graphql/spoke-context.graphql index ccdc523c8..cd61f00fd 100644 --- a/libs/spoke-codegen/src/graphql/spoke-context.graphql +++ b/libs/spoke-codegen/src/graphql/spoke-context.graphql @@ -11,7 +11,6 @@ fragment OrganizationSettingsInfo on OrganizationSettings { id defaulTexterApprovalStatus optOutMessage - numbersApiKey trollbotWebhookUrl showContactLastName showContactCell diff --git a/libs/spoke-codegen/src/pg-types.ts b/libs/spoke-codegen/src/pg-types.ts index 033cbabf6..412a13ddf 100644 --- a/libs/spoke-codegen/src/pg-types.ts +++ b/libs/spoke-codegen/src/pg-types.ts @@ -144,14 +144,13 @@ export interface campaign { limit_assignment_to_teams: boolean updated_at?: Date | null replies_stale_after_minutes?: number | null - landlines_filtered?: boolean | null external_system_id?: string | null is_approved?: boolean | null autosend_status?: string | null - autosend_user_id?: number | null + autosend_user_id?: number | null } -export interface campaign_contact { +export interface campaign_contact { id: number campaign_id: number assignment_id?: number | null @@ -222,7 +221,6 @@ export interface campaign_with_groups { limit_assignment_to_teams?: boolean | null updated_at?: Date | null replies_stale_after_minutes?: number | null - landlines_filtered?: boolean | null external_system_id?: string | null group_name?: string | null group_description?: string | null diff --git a/migrations/20260616120000_drop_landlines_filtered.js b/migrations/20260616120000_drop_landlines_filtered.js new file mode 100644 index 000000000..2be5bc4d9 --- /dev/null +++ b/migrations/20260616120000_drop_landlines_filtered.js @@ -0,0 +1,616 @@ +exports.up = function up(knex) { + return knex.schema.raw(` + drop table filtered_contact; + + drop view campaign cascade; + + alter table all_campaign drop column landlines_filtered; + + 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, + external_system_id, + is_approved, + autosend_status, + autosend_user_id, + messaging_service_sid, + autosend_limit + from all_campaign + where is_template = false; + + create or replace view sendable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status, is_autoassign_enabled + from campaign + where is_started and not is_archived + ); + + create or replace view assignable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status + from sendable_campaigns + where is_autoassign_enabled + ); + + create or replace view assignable_campaign_contacts as ( + select + campaign_contact.id, campaign_contact.campaign_id, + campaign_contact.message_status, campaign.texting_hours_end, + campaign_contact.timezone::text as contact_timezone + from campaign_contact + join campaign on campaign_contact.campaign_id = campaign.id + where assignment_id is null + and is_opted_out = false + and archived = false + and not exists ( + select 1 + from campaign_contact_tag + join tag on campaign_contact_tag.tag_id = tag.id + where tag.is_assignable = false + and campaign_contact_tag.campaign_contact_id = campaign_contact.id + ) + ); + + create or replace view assignable_needs_message as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsMessage' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_needs_reply as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + create or replace view assignable_needs_reply_with_escalation_tags as ( + select acc.id, acc.campaign_id, acc.message_status, acc.applied_escalation_tags + from assignable_campaign_contacts_with_escalation_tags as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view public.missing_external_sync_question_response_configuration as + select + all_values.*, + external_system.id as system_id + from ( + select + istep.campaign_id, + istep.parent_interaction_id as interaction_step_id, + istep.answer_option as value, + exists ( + select 1 + from public.question_response as istep_qr + where + istep_qr.interaction_step_id = istep.parent_interaction_id + and istep_qr.value = istep.answer_option + ) as is_required + from public.interaction_step istep + where istep.parent_interaction_id is not null + union + select + qr_istep.campaign_id, + qr.interaction_step_id, + qr.value, + true as is_required + from public.question_response as qr + join public.interaction_step qr_istep on qr_istep.id = qr.interaction_step_id + ) all_values + join campaign on campaign.id = all_values.campaign_id + join external_system + on external_system.organization_id = campaign.organization_id + where + not exists ( + select 1 + from public.all_external_sync_question_response_configuration aqrc + where + all_values.campaign_id = aqrc.campaign_id + and external_system.id = aqrc.system_id + and all_values.interaction_step_id = aqrc.interaction_step_id + and all_values.value = aqrc.question_response_value + ); + + create view public.external_sync_question_response_configuration as + select + aqrc.id::text as compound_id, + aqrc.campaign_id, + aqrc.system_id, + aqrc.interaction_step_id, + aqrc.question_response_value, + aqrc.created_at, + aqrc.updated_at, + not exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + where qrro.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_activist_code qrac + where qrac.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_result_code qrrc + where qrrc.question_response_config_id = aqrc.id + ) as is_empty, + exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + join external_survey_question_response_option + on external_survey_question_response_option.id = qrro.external_response_option_id + join external_survey_question + on external_survey_question.id = external_survey_question_response_option.external_survey_question_id + where + qrro.question_response_config_id = aqrc.id + and external_survey_question.status <> 'active' + + union + + select 1 from public.external_sync_config_question_response_activist_code qrac + join external_activist_code + on external_activist_code.id = qrac.external_activist_code_id + where + qrac.question_response_config_id = aqrc.id + and external_activist_code.status <> 'active' + ) as includes_not_active, + false as is_missing, + false as is_required + from public.all_external_sync_question_response_configuration aqrc + union + select + missing.value || '|' || missing.interaction_step_id || '|' || missing.campaign_id as compound_id, + missing.campaign_id, + missing.system_id as system_id, + missing.interaction_step_id, + missing.value as question_response_value, + null as created_at, + null as updated_at, + true as is_empty, + false as includes_not_active, + true as is_missing, + missing.is_required + from public.missing_external_sync_question_response_configuration missing + ; + + create or replace view sendable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status, is_autoassign_enabled + from campaign + where is_started and not is_archived + ); + + create or replace view assignable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status + from sendable_campaigns + where is_autoassign_enabled + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + create or replace view autosend_campaigns_to_send as ( + select * + from sendable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = sendable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = sendable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status = 'sending' + ); + `); +}; + +exports.down = function down(knex) { + return knex.schema.raw(` + create table public.filtered_contact ( + id serial primary key, + campaign_id integer not null references public.all_campaign(id), + external_id text not null, + first_name text not null, + last_name text not null, + cell text not null, + zip text not null, + custom_fields text not null, + created_at timestamp with time zone not null, + updated_at timestamp with time zone not null, + timezone character varying(255), + filtered_reason text not null check (filtered_reason = any (array['INVALID'::text, 'LANDLINE'::text, 'VOIP'::text, 'OPTEDOUT'::text])), + constraint filtered_contact_cell_campaign_id_unique unique (cell, campaign_id) + ); + + create index filtered_contact_campaign_id_index on public.filtered_contact using btree (campaign_id); + + drop view campaign cascade; + + alter table all_campaign add column landlines_filtered boolean default false; + + 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; + + create or replace view assignable_campaign_contacts as ( + select + campaign_contact.id, campaign_contact.campaign_id, + campaign_contact.message_status, campaign.texting_hours_end, + campaign_contact.timezone::text as contact_timezone + from campaign_contact + join campaign on campaign_contact.campaign_id = campaign.id + where assignment_id is null + and is_opted_out = false + and archived = false + and not exists ( + select 1 + from campaign_contact_tag + join tag on campaign_contact_tag.tag_id = tag.id + where tag.is_assignable = false + and campaign_contact_tag.campaign_contact_id = campaign_contact.id + ) + ); + + create or replace view assignable_needs_message as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsMessage' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '10 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_needs_reply as ( + select acc.id, acc.campaign_id, acc.message_status + from assignable_campaign_contacts as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + create or replace view assignable_needs_reply_with_escalation_tags as ( + select acc.id, acc.campaign_id, acc.message_status, acc.applied_escalation_tags + from assignable_campaign_contacts_with_escalation_tags as acc + join campaign on campaign.id = acc.campaign_id + where message_status = 'needsResponse' + and ( + ( acc.contact_timezone is null + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) < campaign.texting_hours_end + and extract(hour from CURRENT_TIMESTAMP at time zone campaign.timezone) >= campaign.texting_hours_start + ) + or + ( campaign.texting_hours_end > extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone) + interval '2 minutes') + and campaign.texting_hours_start <= extract(hour from (CURRENT_TIMESTAMP at time zone acc.contact_timezone)) + ) + ) + ); + + create or replace view public.missing_external_sync_question_response_configuration as + select + all_values.*, + external_system.id as system_id + from ( + select + istep.campaign_id, + istep.parent_interaction_id as interaction_step_id, + istep.answer_option as value, + exists ( + select 1 + from public.question_response as istep_qr + where + istep_qr.interaction_step_id = istep.parent_interaction_id + and istep_qr.value = istep.answer_option + ) as is_required + from public.interaction_step istep + where istep.parent_interaction_id is not null + union + select + qr_istep.campaign_id, + qr.interaction_step_id, + qr.value, + true as is_required + from public.question_response as qr + join public.interaction_step qr_istep on qr_istep.id = qr.interaction_step_id + ) all_values + join campaign on campaign.id = all_values.campaign_id + join external_system + on external_system.organization_id = campaign.organization_id + where + not exists ( + select 1 + from public.all_external_sync_question_response_configuration aqrc + where + all_values.campaign_id = aqrc.campaign_id + and external_system.id = aqrc.system_id + and all_values.interaction_step_id = aqrc.interaction_step_id + and all_values.value = aqrc.question_response_value + ); + + create view public.external_sync_question_response_configuration as + select + aqrc.id::text as compound_id, + aqrc.campaign_id, + aqrc.system_id, + aqrc.interaction_step_id, + aqrc.question_response_value, + aqrc.created_at, + aqrc.updated_at, + not exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + where qrro.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_activist_code qrac + where qrac.question_response_config_id = aqrc.id + union + select 1 from public.external_sync_config_question_response_result_code qrrc + where qrrc.question_response_config_id = aqrc.id + ) as is_empty, + exists ( + select 1 from public.external_sync_config_question_response_response_option qrro + join external_survey_question_response_option + on external_survey_question_response_option.id = qrro.external_response_option_id + join external_survey_question + on external_survey_question.id = external_survey_question_response_option.external_survey_question_id + where + qrro.question_response_config_id = aqrc.id + and external_survey_question.status <> 'active' + + union + + select 1 from public.external_sync_config_question_response_activist_code qrac + join external_activist_code + on external_activist_code.id = qrac.external_activist_code_id + where + qrac.question_response_config_id = aqrc.id + and external_activist_code.status <> 'active' + ) as includes_not_active, + false as is_missing, + false as is_required + from public.all_external_sync_question_response_configuration aqrc + union + select + missing.value || '|' || missing.interaction_step_id || '|' || missing.campaign_id as compound_id, + missing.campaign_id, + missing.system_id as system_id, + missing.interaction_step_id, + missing.value as question_response_value, + null as created_at, + null as updated_at, + true as is_empty, + false as includes_not_active, + true as is_missing, + missing.is_required + from public.missing_external_sync_question_response_configuration missing + ; + + create or replace view sendable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status, is_autoassign_enabled + from campaign + where is_started and not is_archived + ); + + create or replace view assignable_campaigns as ( + select id, title, organization_id, limit_assignment_to_teams, autosend_status + from sendable_campaigns + where is_autoassign_enabled + ); + + create or replace view assignable_campaigns_with_needs_message as ( + select * + from assignable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = assignable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = assignable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status <> 'sending' + ); + + create or replace view assignable_campaigns_with_needs_reply as ( + select * + from assignable_campaigns + where exists ( + select 1 + from assignable_needs_reply + where campaign_id = assignable_campaigns.id + ) + ); + + create or replace view autosend_campaigns_to_send as ( + select * + from sendable_campaigns + where + exists ( + select 1 + from assignable_needs_message + where campaign_id = sendable_campaigns.id + ) + and not exists ( + select 1 + from campaign + where campaign.id = sendable_campaigns.id + and now() > date_trunc('day', (due_by + interval '24 hours') at time zone campaign.timezone) + ) + and autosend_status = 'sending' + ); + `); +}; diff --git a/schema-dump.sql b/schema-dump.sql index a09da9b67..074f2fff1 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -215,7 +215,6 @@ CREATE TABLE public.all_campaign ( limit_assignment_to_teams boolean DEFAULT false NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, replies_stale_after_minutes integer, - landlines_filtered boolean DEFAULT false, external_system_id uuid, is_approved boolean DEFAULT false NOT NULL, autosend_status text DEFAULT 'unstarted'::text, @@ -1518,7 +1517,6 @@ CREATE VIEW public.campaign AS all_campaign.limit_assignment_to_teams, all_campaign.updated_at, all_campaign.replies_stale_after_minutes, - all_campaign.landlines_filtered, all_campaign.external_system_id, all_campaign.is_approved, all_campaign.autosend_status, @@ -2452,51 +2450,6 @@ UNION ALTER TABLE public.external_sync_question_response_configuration OWNER TO postgres; --- --- Name: filtered_contact; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.filtered_contact ( - id integer NOT NULL, - campaign_id integer NOT NULL, - external_id text NOT NULL, - first_name text NOT NULL, - last_name text NOT NULL, - cell text NOT NULL, - zip text NOT NULL, - custom_fields text NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - timezone character varying(255), - filtered_reason text NOT NULL, - CONSTRAINT filtered_contact_filtered_reason_check CHECK ((filtered_reason = ANY (ARRAY['INVALID'::text, 'LANDLINE'::text, 'VOIP'::text, 'OPTEDOUT'::text]))) -); - - -ALTER TABLE public.filtered_contact OWNER TO postgres; - --- --- Name: filtered_contact_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres --- - -CREATE SEQUENCE public.filtered_contact_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.filtered_contact_id_seq OWNER TO postgres; - --- --- Name: filtered_contact_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres --- - -ALTER SEQUENCE public.filtered_contact_id_seq OWNED BY public.filtered_contact.id; - - -- -- Name: instance_setting; Type: TABLE; Schema: public; Owner: postgres -- @@ -3555,13 +3508,6 @@ ALTER TABLE ONLY public.canned_response ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.deliverability_report ALTER COLUMN id SET DEFAULT nextval('public.deliverability_report_id_seq'::regclass); --- --- Name: filtered_contact id; Type: DEFAULT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.filtered_contact ALTER COLUMN id SET DEFAULT nextval('public.filtered_contact_id_seq'::regclass); - - -- -- Name: interaction_step id; Type: DEFAULT; Schema: public; Owner: postgres -- @@ -4013,22 +3959,6 @@ ALTER TABLE ONLY public.external_system ADD CONSTRAINT external_system_pkey PRIMARY KEY (id); --- --- Name: filtered_contact filtered_contact_cell_campaign_id_unique; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.filtered_contact - ADD CONSTRAINT filtered_contact_cell_campaign_id_unique UNIQUE (cell, campaign_id); - - --- --- Name: filtered_contact filtered_contact_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.filtered_contact - ADD CONSTRAINT filtered_contact_pkey PRIMARY KEY (id); - - -- -- Name: instance_setting instance_setting_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -4517,13 +4447,6 @@ CREATE INDEX deliverability_report_period_starts_at_index ON public.deliverabili CREATE INDEX deliverability_report_url_path_index ON public.deliverability_report USING btree (url_path); --- --- Name: filtered_contact_campaign_id_index; Type: INDEX; Schema: public; Owner: postgres --- - -CREATE INDEX filtered_contact_campaign_id_index ON public.filtered_contact USING btree (campaign_id); - - -- -- Name: interaction_step_campaign_id_index; Type: INDEX; Schema: public; Owner: postgres -- @@ -5477,14 +5400,6 @@ ALTER TABLE ONLY public.external_sync_opt_out_configuration ADD CONSTRAINT external_sync_opt_out_configuration_system_id_fkey FOREIGN KEY (system_id) REFERENCES public.external_system(id); --- --- Name: filtered_contact filtered_contact_campaign_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.filtered_contact - ADD CONSTRAINT filtered_contact_campaign_id_foreign FOREIGN KEY (campaign_id) REFERENCES public.all_campaign(id); - - -- -- Name: interaction_step interaction_step_campaign_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: postgres -- diff --git a/src/containers/AdminCampaignEdit/index.jsx b/src/containers/AdminCampaignEdit/index.jsx index 45490579b..f65717778 100644 --- a/src/containers/AdminCampaignEdit/index.jsx +++ b/src/containers/AdminCampaignEdit/index.jsx @@ -46,7 +46,6 @@ import CampaignAutoassignModeForm from "./sections/CampaignAutoassignModeForm"; import CampaignBasicsForm from "./sections/CampaignBasicsForm"; import CampaignCannedResponsesForm from "./sections/CampaignCannedResponsesForm"; import CampaignContactsForm from "./sections/CampaignContactsForm"; -import CampaignFilterLandlinesForm from "./sections/CampaignFilterLandlinesForm"; import CampaignGroupsForm from "./sections/CampaignGroupsForm"; import CampaignIntegrationForm from "./sections/CampaignIntegrationForm"; import CampaignInteractionStepsForm from "./sections/CampaignInteractionStepsForm"; @@ -429,28 +428,11 @@ class AdminCampaignEdit extends React.Component { jobResult: this.props.pendingJobsData.campaign.pendingJobs.find( (job) => /contacts/.test(job.jobType) ), - canFilterLandlines: - this.props.organizationData.organization && - !!this.props.organizationData.organization.numbersApiKey, otherCampaigns: this.props.organizationData.organization.campaigns.campaigns.filter( (campaign) => campaign.id !== this.props.match.params.campaignId ) } }, - { - title: "Filtering Landlines", - content: CampaignFilterLandlinesForm, - isStandalone: true, - showForModes: [CampaignBuilderMode.Basic, CampaignBuilderMode.Advanced], - keys: ["landlinesFiltered"], - checkCompleted: () => true, - blocksStarting: false, - expandAfterCampaignStarts: false, - extraProps: {}, - exclude: - this.props.organizationData.organization && - !this.props.organizationData.organization.numbersApiKey - }, { title: "Contact Overlap Management", content: CampaignOverlapManager, diff --git a/src/containers/AdminCampaignEdit/queries.ts b/src/containers/AdminCampaignEdit/queries.ts index 62a435ee2..f6999e403 100644 --- a/src/containers/AdminCampaignEdit/queries.ts +++ b/src/containers/AdminCampaignEdit/queries.ts @@ -16,7 +16,6 @@ export const GET_ORGANIZATION_DATA = gql` lastName displayName } - numbersApiKey messagingServices(active: true) { edges { node { diff --git a/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx index 6eb9e9c73..b87a24eb3 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx @@ -36,7 +36,6 @@ interface ContactSource { interface ContactsValues { contactSql: string | null; contactsFile: File | null; - filterOutLandlines: boolean; excludeCampaignIds: string[]; contactsFilename: string | null; } @@ -52,7 +51,6 @@ interface ContactsCampaign { interface ContactsOrganization { id: string; - numbersApiKey: string; externalSystems: Pick, "pageInfo">; campaigns: { campaigns: { @@ -83,7 +81,6 @@ interface ContactsState { contactsSql: string | null; contactsFile: File | null; externalListId: string | null; - filterOutLandlines: boolean; columnMapping: Array; // UI @@ -106,7 +103,6 @@ class CampaignContactsForm extends React.Component< contactsSql: null, contactsFile: null, externalListId: null, - filterOutLandlines: false, configureMappingOpen: false, columnMapping: [] }; @@ -150,7 +146,6 @@ class CampaignContactsForm extends React.Component< contactsSql, contactsFile, externalListId, - filterOutLandlines, selectedCampaignIds, columnMapping } = this.state; @@ -161,7 +156,6 @@ class CampaignContactsForm extends React.Component< contactSql: contactsSql, contactsFile, externalListId, - filterOutLandlines, excludeCampaignIds: selectedCampaignIds, columnMapping }; @@ -386,7 +380,6 @@ const queries = { query getOrganizationDataForEditContacts($organizationId: String!) { organization(id: $organizationId) { id - numbersApiKey externalSystems { pageInfo { totalCount diff --git a/src/containers/AdminCampaignEdit/sections/CampaignFilterLandlinesForm.tsx b/src/containers/AdminCampaignEdit/sections/CampaignFilterLandlinesForm.tsx deleted file mode 100644 index cf23e472c..000000000 --- a/src/containers/AdminCampaignEdit/sections/CampaignFilterLandlinesForm.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { ApolloQueryResult } from "@apollo/client"; -import { gql } from "@apollo/client"; -import Button from "@material-ui/core/Button"; -import CheckCircleIcon from "@material-ui/icons/CheckCircle"; -import ErrorIcon from "@material-ui/icons/Error"; -import WarningIcon from "@material-ui/icons/Warning"; -import React from "react"; -import { compose } from "recompose"; - -import { loadData } from "../../hoc/with-operations"; -import CampaignFormSectionHeading from "../components/CampaignFormSectionHeading"; -import type { - FullComponentProps, - PendingJobType, - RequiredComponentProps -} from "../components/SectionWrapper"; -import { asSection } from "../components/SectionWrapper"; - -const parseJobResultMessage = ( - job?: PendingJobType -): { message?: string; error?: string; unknown?: string } => { - if (job === undefined) return { unknown: "No job!" }; - - if (job.jobType === "filter_landlines") { - return { unknown: job.resultMessage }; - } - - const { message, error } = JSON.parse(job.resultMessage); - - return { message, error }; -}; - -interface FilterLandlinesValues { - campaignId: string; -} - -interface FilterLandlinesData { - id: string; - landlinesFiltered: boolean; -} - -interface FilterLandlinesInnerProps { - mutations: { - filterLandlines( - payload: FilterLandlinesValues - ): Promise>; - }; - campaignData: { - campaign: FilterLandlinesData; - }; -} - -interface Props extends FullComponentProps, FilterLandlinesInnerProps {} - -interface State { - // UI - isWorking: boolean; -} - -class FilterLandlinesForm extends React.Component { - state: State = { - // UI - isWorking: false - }; - - filterLandlines = async () => { - this.setState({ isWorking: true }); - - try { - const response = await this.props.mutations.filterLandlines({ - campaignId: this.props.campaignId - }); - - if (response.errors) throw response.errors; - } catch (ex) { - this.props.onError(ex.message); - } finally { - this.setState({ isWorking: false }); - } - }; - - render() { - const { isWorking } = this.state; - const { landlinesFiltered } = this.props.campaignData.campaign; - const filterJob = this.props.pendingJob; - const { message, error, unknown } = parseJobResultMessage(filterJob); - - return ( -
- -

- Filtering landlines or otherwise un-textable numbers will cost - $.0025 (1/4 cent) per phone number, but as long as more than - 10-20% of your phone numbers are likely to be invalid, it will - save you money. -

-

- If you're pretty sure your phone numbers are valid, skip this - section! -

- - ) - } - /> - {!landlinesFiltered && ( - - )} - {landlinesFiltered && message && ( -
- - {message} -
- )} - {landlinesFiltered && error && ( -
- - {error} -
- )} - {landlinesFiltered && unknown && ( -
- - {unknown} -
- )} -
- ); - } -} - -const queries = { - campaignData: { - query: gql` - query getCampaignContacts($campaignId: String!) { - campaign(id: $campaignId) { - id - landlinesFiltered - } - } - `, - options: (ownProps: Props) => ({ - variables: { - campaignId: ownProps.campaignId - } - }) - }, - organizationData: { - query: gql` - query getOrganizationDataForEditContacts($organizationId: String!) { - organization(id: $organizationId) { - id - numbersApiKey - } - } - `, - options: (ownProps: Props) => ({ - variables: { - organizationId: ownProps.organizationId - } - }) - } -}; - -const mutations = { - filterLandlines: (ownProps: Props) => () => ({ - mutation: gql` - mutation filterLandlines($campaignId: String!) { - filterLandlines(id: $campaignId) { - id - landlinesFiltered - } - } - `, - variables: { - campaignId: ownProps.campaignData.campaign.id - } - }) -}; - -export default compose( - asSection({ - title: "Filter Landlines", - readinessName: "contacts", - jobQueueNames: ["filter-landlines", "filter_landlines"], - expandAfterCampaignStarts: false, - expandableBySuperVolunteers: false - }), - loadData({ - queries, - mutations - }) -)(FilterLandlinesForm); diff --git a/src/containers/Settings/components/queries.ts b/src/containers/Settings/components/queries.ts index 025ba1ef8..cbc1e4ae8 100644 --- a/src/containers/Settings/components/queries.ts +++ b/src/containers/Settings/components/queries.ts @@ -59,7 +59,6 @@ export const GET_ORGANIZATION_SETTINGS = gql` settings { id optOutMessage - numbersApiKey trollbotWebhookUrl defaulTexterApprovalStatus showContactLastName @@ -79,7 +78,6 @@ export const EDIT_ORGANIZATION_SETTINGS = gql` editOrganizationSettings(id: $id, input: $input) { id optOutMessage - numbersApiKey trollbotWebhookUrl defaulTexterApprovalStatus showContactLastName diff --git a/src/schema.graphql b/src/schema.graphql index 645238f89..958e2c45c 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -254,7 +254,6 @@ type RootMutation { editCampaign(id:String!, campaign:CampaignInput!): Campaign saveCampaignGroups(organizationId: String!, campaignGroups: [CampaignGroupInput!]!): [CampaignGroup!]! deleteCampaignGroup(organizationId: String!, campaignGroupId: String!): Boolean! - filterLandlines(id:String!): Campaign bulkUpdateScript(organizationId:String!, findAndReplace: BulkUpdateScriptInput!): [ScriptUpdateResult] deleteJob(campaignId:String!, id:String!): JobRequest copyCampaign(id: String!): Campaign @@ -684,7 +683,6 @@ type Campaign { timezone: String createdAt: String! previewUrl: String - landlinesFiltered: Boolean! externalSystem: ExternalSystem syncReadiness: ExternalSyncReadinessState! externalSyncConfigurations(after: Cursor, first: Int): ExternalSyncQuestionResponseConfigPage! @@ -749,7 +747,6 @@ input CampaignInput { contacts: [CampaignContactInput!] contactsFile: Upload externalListId: String - filterOutLandlines: Boolean excludeCampaignIds: [String!] contactSql: String organizationId: String diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 861c7d05b..bb5d7b385 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -486,7 +486,6 @@ export const resolvers = { "textingHoursEnd", "isAutoassignEnabled", "createdAt", - "landlinesFiltered", "messagingServiceSid", "autosendLimit", "columnMapping" diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 6dbf02fdb..dbe8d7766 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -556,10 +556,6 @@ export const editCampaign = async ( ) { await r.knex("campaign_contact").where({ campaign_id: id }).delete(); await r.knex("filtered_contact").where({ campaign_id: id }).delete(); - await r - .knex("campaign") - .where({ id }) - .update({ landlines_filtered: false }); await r.knex.raw( `select * from public.queue_load_list_into_campaign(?, ?)`, [id, parseInt(campaign.externalListId, 10)] @@ -603,15 +599,7 @@ 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 }); + await r.knex("campaign").update({ external_system_id: null }).where({ id }); const contactsToSave = campaign.contacts.map((datum) => { const modelData = { @@ -631,7 +619,6 @@ export const editCampaign = async ( const jobPayload = { excludeCampaignIds: campaign.excludeCampaignIds || [], contacts: contactsToSave, - filterOutLandlines: campaign.filterOutLandlines, validationStats }; const compressedString: Buffer = (await gzip( @@ -658,13 +645,7 @@ export const editCampaign = async ( datawarehouse && user.is_superadmin ) { - await r - .knex("campaign") - .update({ - external_system_id: null, - landlines_filtered: false - }) - .where({ id }); + await r.knex("campaign").update({ external_system_id: null }).where({ id }); await accessRequired(user, organizationId, "ADMIN", /* superadmin */ true); const [job] = await r .knex("job_request") diff --git a/src/server/api/organization-settings.ts b/src/server/api/organization-settings.ts index 7245703ad..6db3261c7 100644 --- a/src/server/api/organization-settings.ts +++ b/src/server/api/organization-settings.ts @@ -17,7 +17,6 @@ export enum CampaignBuilderMode { interface IOrganizationSettings { defaulTexterApprovalStatus: string; optOutMessage: string; - numbersApiKey?: string; trollbotWebhookUrl?: string; showContactLastName: boolean; showContactCell: boolean; @@ -45,7 +44,6 @@ const SETTINGS_PERMISSIONS: { defaultCampaignBuilderMode: UserRoleType.SUPERVOLUNTEER, defaultAutosendingControlsMode: UserRoleType.ADMIN, defaulTexterApprovalStatus: UserRoleType.OWNER, - numbersApiKey: UserRoleType.OWNER, trollbotWebhookUrl: UserRoleType.OWNER, maxSmsSegmentLength: UserRoleType.TEXTER }; @@ -58,7 +56,6 @@ const SETTINGS_WRITE_PERMISSIONS: { showContactCell: UserRoleType.OWNER, confirmationClickForScriptLinks: UserRoleType.OWNER, defaulTexterApprovalStatus: UserRoleType.OWNER, - numbersApiKey: UserRoleType.OWNER, trollbotWebhookUrl: UserRoleType.OWNER, scriptPreviewForSupervolunteers: UserRoleType.OWNER, defaultCampaignBuilderMode: UserRoleType.OWNER, @@ -93,25 +90,9 @@ const SETTINGS_DEFAULTS: IOrganizationSettings = { maxSmsSegmentLength: 3 }; -const SETTINGS_TRANSFORMERS: Partial< - { - [key in keyof IOrganizationSettings]: ( - value: string - ) => IOrganizationSettings[key]; - } -> = { - numbersApiKey: (value: string) => `${value.slice(0, 4)}****************` -}; - const SETTINGS_VALIDATORS: { [key in keyof IOrganizationSettings]?: { (value: string): void }; } = { - numbersApiKey: (value: string) => { - // User probably made a mistake - no API key will have a * - if (value.includes("*")) { - throw new Error("Numbers API Key cannot have character: *"); - } - }, trollbotWebhookUrl: (value: string) => { if (!stringIsAValidUrl(value)) { throw new Error("TrollBot webhook URL must be a valid URL"); @@ -142,11 +123,6 @@ export const getOrgFeature = ( const returnValue = foundValue === undefined ? defaultValue ?? null : foundValue; - const transformer = SETTINGS_TRANSFORMERS[featureName]; - if (transformer && returnValue) { - const result = transformer(returnValue); - return result as IOrganizationSettings[T]; - } return returnValue; } catch (_err) { return SETTINGS_DEFAULTS[featureName] ?? null; @@ -181,7 +157,6 @@ export const resolvers = { ...settingResolvers([ "defaulTexterApprovalStatus", "optOutMessage", - "numbersApiKey", "trollbotWebhookUrl", "showContactLastName", "showContactCell", diff --git a/src/server/api/organization.js b/src/server/api/organization.js index 4bb87cf17..74d9ba42a 100644 --- a/src/server/api/organization.js +++ b/src/server/api/organization.js @@ -336,18 +336,6 @@ export const resolvers = { const escalatedCount = await r.parseCount(countQuery); return escalatedCount; }, - numbersApiKey: async (organization) => { - let numbersApiKey = null; - - try { - const features = JSON.parse(organization.features); - numbersApiKey = `${features.numbersApiKey.slice(0, 4)}****************`; - } catch (ex) { - // no-op - } - - return numbersApiKey; - }, pendingAssignmentRequestCount: async (organization) => config.DISABLE_SIDEBAR_BADGES ? 0 diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index 49d2f2e20..2e65dbb29 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -33,7 +33,6 @@ import { addExportMultipleCampaigns } from "../tasks/chunk-tasks/export-multiple import { addMarkSecondPass } from "../tasks/chunk-tasks/mark-second-pass"; import { addExportForVan } from "../tasks/export-for-van"; import { TASK_IDENTIFIER as exportOptOutsIdentifier } from "../tasks/export-opt-outs"; -import { addFilterLandlines } from "../tasks/filter-landlines"; import { QUEUE_AUTOSEND_ORGANIZATION_INITIALS_TASK_IDENTIFIER } from "../tasks/queue-autosend-initials"; import { getWorker } from "../worker"; import { giveUserMoreTexts, myCurrentAssignmentTarget } from "./assignment"; @@ -1075,28 +1074,6 @@ const rootMutations = { ); }, - filterLandlines: async (_root, { id }, { user, loaders }) => { - const campaign = await r.knex("campaign").where({ id }).first(); - - await accessRequired(user, campaign.organization_id, "ADMIN"); - - if (campaign.is_started) { - throw new GraphQLError( - "Not allowed to filter landlines after the campaign starts" - ); - } - - if (campaign.landlines_filtered) { - throw new GraphQLError( - "Landlines already filtered. You may need to wait for current contact upload to finish." - ); - } - - await addFilterLandlines({ campaignId: campaign.id }); - - return loaders.campaign.load(id); - }, - bulkUpdateScript: async ( _root, { organizationId, findAndReplace }, @@ -1224,12 +1201,6 @@ const rootMutations = { maxRequestCount: 100, defaulTexterApprovalStatus: RequestAutoApproveType.APPROVAL_REQUIRED }; - if (payload.org_features) { - const { switchboard_lrn_api_key } = payload.org_features; - if (switchboard_lrn_api_key) { - orgFeatures.numbersApiKey = switchboard_lrn_api_key; - } - } const insertResult = await trx("organization") .insert({ diff --git a/src/server/api/types.ts b/src/server/api/types.ts index 04ef18bc2..384b0692f 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -36,7 +36,6 @@ export enum ExternalSystemType { export enum FilteredContactReason { Invalid = "INVALID", - Landline = "LANDLINE", VOIP = "VOIP", OptedOut = "OPTEDOUT" } @@ -119,7 +118,6 @@ export interface CampaignRecord { limit_assignment_to_teams: boolean; updated_at: string; replies_stale_after_minutes: number | null; - landlines_filtered: boolean; external_system_id: string | null; autosend_status: AutosendStatus; autosend_user_id: number; diff --git a/src/server/organization-settings.spec.ts b/src/server/organization-settings.spec.ts index d5b2449e3..9b9b0d90b 100644 --- a/src/server/organization-settings.spec.ts +++ b/src/server/organization-settings.spec.ts @@ -36,7 +36,6 @@ describe("get organization settings", () => { scriptPreviewForSupervolunteers: false, defaultAutosendingControlsMode: AutosendingControlsMode.Basic, defaulTexterApprovalStatus: RequestAutoApproveType.APPROVAL_REQUIRED, - numbersApiKey: "SomethingSecret", trollbotWebhookUrl: "https://withtheranks.com/trolls", maxSmsSegmentLength: 3 }; @@ -67,7 +66,6 @@ describe("get organization settings", () => { scriptPreviewForSupervolunteers defaultAutosendingControlsMode defaulTexterApprovalStatus - numbersApiKey trollbotWebhookUrl maxSmsSegmentLength } @@ -97,7 +95,6 @@ describe("get organization settings", () => { features.confirmationClickForScriptLinks ); expect(settings.id).not.toBeNull(); - expect(settings.numbersApiKey).toBeNull(); expect(settings.trollbotWebhookUrl).toBeNull(); }); @@ -135,7 +132,6 @@ describe("get organization settings", () => { expect(settings.defaulTexterApprovalStatus).toEqual( features.defaulTexterApprovalStatus ); - expect(settings.numbersApiKey).not.toBeNull(); expect(settings.trollbotWebhookUrl).toEqual(features.trollbotWebhookUrl); expect(settings.maxSmsSegmentLength).toEqual(features.maxSmsSegmentLength); }); 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..af87052d8 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 @@ -16,7 +16,6 @@ import { } from "../../api/lib/contact-list"; import type { CampaignContactRecord } from "../../api/types"; import { withTransaction } from "../../utils"; -import { addFilterLandlines } from "../filter-landlines"; export const TASK_IDENTIFIER = "import-contact-csv-from-url"; @@ -94,7 +93,6 @@ export interface ImportContactCsvFromUrlPayload { campaignId: number; signedDownloadUrl: string; columnMapping: ColumnMapping; - initiateFilterLandlines: boolean; limit?: number; offset?: number; } @@ -107,7 +105,6 @@ export const importContactCsvFromUrl: Task = async ( campaignId, signedDownloadUrl, columnMapping, - initiateFilterLandlines, offset, limit } = payload; @@ -164,7 +161,7 @@ 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`, + `update campaign set external_system_id = null where id = $1`, [campaignId] ); await trx.query(`delete from campaign_contact where campaign_id = $1`, [ @@ -203,9 +200,5 @@ export const importContactCsvFromUrl: Task = async ( }); }); - if (initiateFilterLandlines) { - await addFilterLandlines({ campaignId }); - } - await notifyLargeCampaignEvent(campaignId, "upload"); }; diff --git a/src/server/tasks/filter-landlines.ts b/src/server/tasks/filter-landlines.ts deleted file mode 100644 index a76ded90b..000000000 --- a/src/server/tasks/filter-landlines.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { FilteredContactReason } from "../api/types"; -import { makeNumbersClient } from "../lib/assemble-numbers"; -import type { ProgressJobPayload, ProgressTask } from "./utils"; -import { addProgressJob } from "./utils"; - -export const TASK_IDENTIFIER = "filter-landlines"; - -const LRN_BATCH_SIZE = 1000; - -interface FilterRow { - id: number; - cell: string; -} - -export const filterLandlines: ProgressTask = async ( - payload, - helpers -) => { - const { campaignId } = payload; - - const { - rows: [{ features }] - } = await helpers.query( - ` - select features - from organization - join campaign on campaign.organization_id = organization.id - where campaign.id = $1 - `, - [campaignId] - ); - - const orgFeatures = JSON.parse(features || "{}"); - - const { numbersApiKey } = orgFeatures; - - if (!numbersApiKey) { - throw new Error("Cannot filter landlines - no numbers api key configured"); - } - - const numbersClient = makeNumbersClient({ apiKey: numbersApiKey }); - const numbersRequest = await numbersClient.lookup.createRequest(); - - let highestId = 0; - let nextBatch: FilterRow[] = []; - - do { - const result = await helpers.query( - ` - select id, cell - from campaign_contact - where - campaign_id = $1 - and id > $2 - order by id asc - limit $3 - `, - [campaignId, highestId, LRN_BATCH_SIZE] - ); - - nextBatch = result.rows; - - if (nextBatch.length > 0) { - highestId = nextBatch[nextBatch.length - 1].id; - const batchCells = nextBatch.map((cc) => cc.cell); - await numbersRequest.addPhoneNumbers(batchCells as any); - } - } while (nextBatch.length > 0); - - await numbersRequest.close(); - - await numbersRequest.waitUntilDone({ - onProgressUpdate: async (percentComplete) => { - await helpers.updateStatus(Math.round(percentComplete * 100)); - } - }); - - let landlinesFilteredOut = 0; - - const deleteNumbers = (reason: string) => async ( - numbers: { phoneNumber: string }[] - ) => { - landlinesFilteredOut += numbers.length; - await helpers.query( - ` - with deleted_contacts as ( - delete from campaign_contact - where - campaign_id = $1 - and cell = any ($2) - returning * - ) - insert into filtered_contact ( - campaign_id, - external_id, - first_name, - last_name, - cell, - zip, - custom_fields, - created_at, - updated_at, - timezone, - filtered_reason - ) - select - campaign_id, - external_id, - first_name, - last_name, - cell, - zip, - custom_fields, - created_at, - updated_at, - timezone, - $3 - from deleted_contacts - `, - [campaignId, numbers.map((n) => n.phoneNumber), reason] - ); - }; - - await numbersRequest.landlines.eachPage({ - onPage: deleteNumbers(FilteredContactReason.Landline) - }); - - await numbersRequest.invalids.eachPage({ - onPage: deleteNumbers(FilteredContactReason.Invalid) - }); - - await numbersRequest.voips.eachPage({ - onPage: deleteNumbers(FilteredContactReason.VOIP) - }); - - // Setting result_message marks the job as complete - await Promise.all([ - helpers.updateResult({ - message: `${landlinesFilteredOut} contacts removed because they were landlines, voips, or invalid` - }), - helpers.query( - ` - update campaign - set landlines_filtered = true - where id = $1 - `, - [campaignId] - ) - ]); -}; - -export const addFilterLandlines = async (payload: ProgressJobPayload) => - addProgressJob({ - identifier: TASK_IDENTIFIER, - payload - }); diff --git a/src/server/worker.ts b/src/server/worker.ts index 32c4d8e30..5330170e3 100644 --- a/src/server/worker.ts +++ b/src/server/worker.ts @@ -30,10 +30,6 @@ import { exportOptOuts, TASK_IDENTIFIER as exportOptOutsIdentifier } from "./tasks/export-opt-outs"; -import { - filterLandlines, - TASK_IDENTIFIER as filterLandlinesIdentifier -} from "./tasks/filter-landlines"; import handleAutoassignmentRequest from "./tasks/handle-autoassignment-request"; import handleDeliveryReport from "./tasks/handle-delivery-report"; import { @@ -146,9 +142,6 @@ export const getWorker = async (attempt = 0): Promise => { [exportForVanIdentifier]: wrapProgressTask(exportForVan, { removeOnComplete: true }), - [filterLandlinesIdentifier]: wrapProgressTask(filterLandlines, { - removeOnComplete: false - }), [assignTextersIdentifier]: wrapProgressTask(assignTexters, { removeOnComplete: true }),