diff --git a/__test__/testbed-preparation/core.ts b/__test__/testbed-preparation/core.ts index 68b972c01..4810ae807 100644 --- a/__test__/testbed-preparation/core.ts +++ b/__test__/testbed-preparation/core.ts @@ -407,7 +407,10 @@ export const createCompleteCampaign = async ( ? optOrg : await createOrganization(client, optOrg ?? {}); + const creator = await createTexter(client, {}); + const campaign = await createCampaign(client, { + creatorId: creator.id, ...(options.campaign ?? {}), organizationId: organization.id }); diff --git a/libs/gql-schema/campaign.ts b/libs/gql-schema/campaign.ts index 1a5effec0..cbebaf7c6 100644 --- a/libs/gql-schema/campaign.ts +++ b/libs/gql-schema/campaign.ts @@ -70,11 +70,6 @@ export const schema = ` INCLUDES_NOT_ACTIVE_TARGETS } - type CsvColumnMapping { - column: String! - remap: String! - } - type Campaign { id: ID! organization: Organization! @@ -83,39 +78,39 @@ export const schema = ` dueBy: Date readiness: CampaignReadiness! isApproved: Boolean! - isStarted: Boolean - isArchived: Boolean + isStarted: Boolean! + isArchived: Boolean! isTemplate: Boolean! - creator: User - texters: [User] - assignments(assignmentsFilter: AssignmentsFilter): [Assignment] - interactionSteps: [InteractionStep] + creator: User! + texters: [User!]! + assignments(assignmentsFilter: AssignmentsFilter): [Assignment!]! + interactionSteps: [InteractionStep!]! invalidScriptFields: [String!]! - contacts: [CampaignContact] - contactsCount: Int - hasUnassignedContacts: Boolean - hasUnsentInitialMessages: Boolean - hasUnhandledMessages: Boolean + contacts: [CampaignContact!]! + contactsCount: Int! + hasUnassignedContacts: Boolean! + hasUnsentInitialMessages: Boolean! + hasUnhandledMessages: Boolean! hasSentMessages: Boolean! - customFields: [String] + customFields: [String!]! customFieldAverageLengths: JSON! cannedResponses(userId: String): [CannedResponse!]! - stats: CampaignStats, - pendingJobs(jobTypes: [String]): [JobRequest]! - datawarehouseAvailable: Boolean + stats: CampaignStats! + pendingJobs(jobTypes: [String]): [JobRequest!]! + datawarehouseAvailable: Boolean! introHtml: String primaryColor: String logoImageUrl: String - editors: String + editors: String! teams: [Team!]! campaignGroups: CampaignGroupPage campaignVariables: [CampaignVariable!]! - textingHoursStart: Int - textingHoursEnd: Int + textingHoursStart: Int! + textingHoursEnd: Int! isAutoassignEnabled: Boolean! repliesStaleAfter: Int isAssignmentLimitedToTeams: Boolean! - timezone: String + timezone: String! createdAt: String! previewUrl: String landlinesFiltered: Boolean! @@ -126,7 +121,6 @@ export const schema = ` autosendStatus: String! messagingServiceSid: String autosendLimit: Int - columnMapping: [CsvColumnMapping!] messagingService: MessagingService contactsFilename: String } diff --git a/migrations/20260816013052_campaign_not_null_columns.js b/migrations/20260816013052_campaign_not_null_columns.js new file mode 100644 index 000000000..8c00b0684 --- /dev/null +++ b/migrations/20260816013052_campaign_not_null_columns.js @@ -0,0 +1,87 @@ +exports.up = async function up(knex) { + // These shouldn't do anything, but just in case there are null values, + // we want to set them to the default value before making the columns not nullable. + await knex("all_campaign") + .whereNull("is_started") + .update({ is_started: false }); + // if there's somehow a null value for is_archived, + // we want to set it to true so that the campaign doesn't show up in the UI + await knex("all_campaign") + .whereNull("is_archived") + .update({ is_archived: true }); + await knex("all_campaign") + .whereNull("texting_hours_start") + .update({ texting_hours_start: 9 }); + await knex("all_campaign") + .whereNull("texting_hours_end") + .update({ texting_hours_end: 21 }); + await knex("all_campaign") + .whereNull("timezone") + .update({ timezone: "America/New_York" }); + // just picking the 1st user, no easy way to derive this + await knex("all_campaign").whereNull("creator_id").update({ creator_id: 1 }); + + return knex.schema.alterTable("all_campaign", (table) => { + table + .boolean("is_started") + .notNullable() + .defaultTo(false) + .alter({ alterNullable: true, alterType: false }); + table + .boolean("is_archived") + .notNullable() + .defaultTo(false) + .alter({ alterNullable: true, alterType: false }); + table + .integer("texting_hours_start") + .notNullable() + .defaultTo(9) + .alter({ alterNullable: true, alterType: false }); + table + .integer("texting_hours_end") + .notNullable() + .defaultTo(21) + .alter({ alterNullable: true, alterType: false }); + table + .text("timezone") + .notNullable() + .defaultTo("America/New_York") + .alter({ alterNullable: true, alterType: false }); + table + .integer("creator_id") + .notNullable() + .alter({ alterNullable: true, alterType: false }); + }); +}; + +exports.down = function down(knex) { + return knex.schema.alterTable("all_campaign", (table) => { + table + .boolean("is_started") + .nullable() + .alter({ alterNullable: true, alterType: false }); + table + .boolean("is_archived") + .nullable() + .alter({ alterNullable: true, alterType: false }); + table + .integer("texting_hours_start") + .nullable() + .defaultTo(9) + .alter({ alterNullable: true, alterType: false }); + table + .integer("texting_hours_end") + .nullable() + .defaultTo(21) + .alter({ alterNullable: true, alterType: false }); + table + .text("timezone") + .nullable() + .defaultTo("America/New_York") + .alter({ alterNullable: true, alterType: false }); + table + .integer("creator_id") + .nullable() + .alter({ alterNullable: true, alterType: false }); + }); +}; diff --git a/schema-dump.sql b/schema-dump.sql index 5d6ad2704..155687d30 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -200,17 +200,17 @@ CREATE TABLE public.all_campaign ( organization_id integer NOT NULL, title text DEFAULT ''::text NOT NULL, description text DEFAULT ''::text NOT NULL, - is_started boolean, + is_started boolean DEFAULT false NOT NULL, due_by timestamp with time zone, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - is_archived boolean, + is_archived boolean DEFAULT false NOT NULL, logo_image_url text, intro_html text, primary_color text, - texting_hours_start integer DEFAULT 9, - texting_hours_end integer DEFAULT 21, - timezone text DEFAULT 'America/New_York'::text, - creator_id integer, + texting_hours_start integer DEFAULT 9 NOT NULL, + texting_hours_end integer DEFAULT 21 NOT NULL, + timezone text DEFAULT 'America/New_York'::text NOT NULL, + creator_id integer NOT NULL, is_autoassign_enabled boolean DEFAULT false NOT NULL, limit_assignment_to_teams boolean DEFAULT false NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, diff --git a/src/containers/AdminAutosending/components/AutosendingLimitField.tsx b/src/containers/AdminAutosending/components/AutosendingLimitField.tsx index 2a3063ee8..7c47c92ad 100644 --- a/src/containers/AdminAutosending/components/AutosendingLimitField.tsx +++ b/src/containers/AdminAutosending/components/AutosendingLimitField.tsx @@ -31,7 +31,7 @@ export const AutosendingLimitField: React.FC = ({ ] = useUpdateCampaignAutosendingLimitMutation(); const countMessagedContacts = useMemo( - () => data?.campaign?.stats?.countMessagedContacts, + () => data?.campaign?.stats.countMessagedContacts, [data] ); diff --git a/src/containers/AdminAutosending/components/AutosendingTargetRow.tsx b/src/containers/AdminAutosending/components/AutosendingTargetRow.tsx index fcf8560cf..6e5550a12 100644 --- a/src/containers/AdminAutosending/components/AutosendingTargetRow.tsx +++ b/src/containers/AdminAutosending/components/AutosendingTargetRow.tsx @@ -27,7 +27,13 @@ export const AutosendingTargetRow: React.FC = ( const { target, organizationId, disabled = false, onStart, onPause } = props; const chipClasses = useChipStyles(); - const totalSent = target.stats?.countMessagedContacts; + const { + countMessagedContacts: totalSent, + percentUnhandledReplies, + needsMessageOptOutsCount, + receivedMessagesCount, + optOutsCount + } = target.stats; const statusChipDisplay = target.autosendStatus; const chipRootClass = @@ -39,9 +45,7 @@ export const AutosendingTargetRow: React.FC = ( ? chipClasses.complete : chipClasses.unstarted; - const hasHighUnhandledReplies = - target.stats?.percentUnhandledReplies !== undefined && - target.stats.percentUnhandledReplies > 25; + const hasHighUnhandledReplies = percentUnhandledReplies > 25; const repliesColor = hasHighUnhandledReplies ? red[600] : "black"; const waitingToDeliver = @@ -85,18 +89,14 @@ export const AutosendingTargetRow: React.FC = ( {target.contactsCount} {target.deliverabilityStats.deliveredCount} - {target.contactsCount! - - totalSent! - - (target.stats?.needsMessageOptOutsCount || 0)} + {target.contactsCount - totalSent - needsMessageOptOutsCount} {waitingToDeliver} {target.deliverabilityStats.errorCount} - - {target.stats?.receivedMessagesCount} - + {receivedMessagesCount} - {target.stats?.optOutsCount} + {optOutsCount} diff --git a/src/containers/AdminCampaignEdit/queries.ts b/src/containers/AdminCampaignEdit/queries.ts index 8c440265b..ccffaabbd 100644 --- a/src/containers/AdminCampaignEdit/queries.ts +++ b/src/containers/AdminCampaignEdit/queries.ts @@ -96,10 +96,6 @@ export const EditCampaignFragment = gql` } messagingServiceSid editors - columnMapping { - column - remap - } readiness { basics textingHours diff --git a/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx index 6eb9e9c73..4c700af88 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignContactsForm/index.tsx @@ -367,10 +367,6 @@ const queries = { id } datawarehouseAvailable - columnMapping { - column - remap - } contactsFilename } } diff --git a/src/containers/AdminCampaignStats/components/TexterStats.tsx b/src/containers/AdminCampaignStats/components/TexterStats.tsx index fad005313..2665808be 100644 --- a/src/containers/AdminCampaignStats/components/TexterStats.tsx +++ b/src/containers/AdminCampaignStats/components/TexterStats.tsx @@ -56,10 +56,9 @@ export const TexterStats: React.FC = ({ campaignId }) => { - {assignments.map( - (assignment) => - assignment && - )} + {assignments.map((assignment) => ( + + ))} diff --git a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/SurveyColumn/ManageSurveyResponses.tsx b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/SurveyColumn/ManageSurveyResponses.tsx index 6de5cd30a..f4b0a91af 100644 --- a/src/containers/AdminIncomingMessageList/components/IncomingMessageList/SurveyColumn/ManageSurveyResponses.tsx +++ b/src/containers/AdminIncomingMessageList/components/IncomingMessageList/SurveyColumn/ManageSurveyResponses.tsx @@ -61,15 +61,14 @@ export const ManageSurveyResponses: React.FC = ( useEffect(() => { const { interactionSteps } = props.campaign; - const newQuestionResponses = interactionSteps?.reduce( + const newQuestionResponses = interactionSteps.reduce( (collector, iStep) => { - if (!iStep) return collector; const value = iStep.questionResponse?.value; return value ? { ...collector, [iStep.id]: value } : collector; }, {} ); - setQuestionResponses(newQuestionResponses ?? {}); + setQuestionResponses(newQuestionResponses); }, [props.campaign.interactionSteps]); const getResponsesFrom = (startingStepId: string) => { @@ -77,26 +76,24 @@ export const ManageSurveyResponses: React.FC = ( const iSteps: (InteractionStep & { children: InteractionStep[] })[] = []; let currentStep: InteractionStep | null = - interactionSteps?.find( - (iStep) => iStep?.questionText && iStep.id === startingStepId + interactionSteps.find( + (iStep) => iStep.questionText && iStep.id === startingStepId ) ?? null; while (currentStep) { const currentStepId = currentStep.id; - const children = ( - interactionSteps?.filter( - (iStep) => iStep?.parentInteractionId === currentStepId - ) ?? [] - ).filter((iStep): iStep is InteractionStep => iStep !== null); + const children = interactionSteps.filter( + (iStep) => iStep.parentInteractionId === currentStepId + ); iSteps.push({ ...currentStep, children }); const value = questionResponses[currentStep.id]; currentStep = value ? // Only show actionable questions - children?.find( - (iStep) => iStep?.questionText && iStep.answerOption === value + children.find( + (iStep) => iStep.questionText && iStep.answerOption === value ) ?? null : null; } @@ -178,8 +175,8 @@ export const ManageSurveyResponses: React.FC = ( const { interactionSteps } = props.campaign; - const startingStep = interactionSteps?.find( - (iStep) => iStep?.parentInteractionId === null + const startingStep = interactionSteps.find( + (iStep) => iStep.parentInteractionId === null ); // There may not be an interaction step, or it may not define a question diff --git a/src/containers/AdminTemplateCampaigns/components/TemplateCampaignRow.tsx b/src/containers/AdminTemplateCampaigns/components/TemplateCampaignRow.tsx index 8aadc2a1f..77ac87603 100644 --- a/src/containers/AdminTemplateCampaigns/components/TemplateCampaignRow.tsx +++ b/src/containers/AdminTemplateCampaigns/components/TemplateCampaignRow.tsx @@ -133,7 +133,7 @@ export const TemplateCampaignRow: React.FC = ({
- Created {createdAt} by {templateCampaign.creator?.displayName} + Created {createdAt} by {templateCampaign.creator.displayName} } diff --git a/src/containers/CampaignList/components/CampaignDetails.tsx b/src/containers/CampaignList/components/CampaignDetails.tsx index 9221e8d85..3fcc02349 100644 --- a/src/containers/CampaignList/components/CampaignDetails.tsx +++ b/src/containers/CampaignList/components/CampaignDetails.tsx @@ -30,8 +30,8 @@ const useStyles = makeStyles({ interface CampaignDetailsProps { id: string; description: string; - creatorName: string | null; - hasUnassignedContacts: boolean | null | undefined; + creatorName: string; + hasUnassignedContacts: boolean; teams: CampaignListEntryFragment["teams"]; campaignGroups: CampaignListEntryFragment["campaignGroups"]; externalSystem: Pick | null | undefined; diff --git a/src/containers/CampaignList/components/CampaignListRow.tsx b/src/containers/CampaignList/components/CampaignListRow.tsx index 7b7d2e948..22c9ac21d 100644 --- a/src/containers/CampaignList/components/CampaignListRow.tsx +++ b/src/containers/CampaignList/components/CampaignListRow.tsx @@ -58,7 +58,7 @@ export const CampaignListRow: React.FC = (props) => { const classes = useStyles(); - const creatorName = campaign.creator ? campaign.creator.displayName : null; + const creatorName = campaign.creator.displayName; const isAutoAssignEligible = !!( isStarted && diff --git a/src/containers/CampaignList/utils.ts b/src/containers/CampaignList/utils.ts index 56659c695..9db8dd5a2 100644 --- a/src/containers/CampaignList/utils.ts +++ b/src/containers/CampaignList/utils.ts @@ -118,10 +118,10 @@ export const isCampaignGroupsPermissionError = (gqlError: GraphQLError) => { type MakeCampaignTagsFn = (props: { organizationId: string; campaignId: string; - isStarted: boolean | null | undefined; + isStarted: boolean; isAutoAssignEligible: boolean; - hasUnsentInitialMessages: boolean | null | undefined; - hasUnhandledMessages: boolean | null | undefined; + hasUnsentInitialMessages: boolean; + hasUnhandledMessages: boolean; onNavigate: (path: string) => void; onToggleAutoassign: () => void; }) => Tag[]; diff --git a/src/schema.graphql b/src/schema.graphql index 0773c937a..d915430f3 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -638,11 +638,6 @@ enum ExternalSyncReadinessState { INCLUDES_NOT_ACTIVE_TARGETS } -type CsvColumnMapping { - column: String! - remap: String! -} - type Campaign { id: ID! organization: Organization! @@ -651,39 +646,39 @@ type Campaign { dueBy: Date readiness: CampaignReadiness! isApproved: Boolean! - isStarted: Boolean - isArchived: Boolean + isStarted: Boolean! + isArchived: Boolean! isTemplate: Boolean! - creator: User - texters: [User] - assignments(assignmentsFilter: AssignmentsFilter): [Assignment] - interactionSteps: [InteractionStep] + creator: User! + texters: [User!]! + assignments(assignmentsFilter: AssignmentsFilter): [Assignment!]! + interactionSteps: [InteractionStep!]! invalidScriptFields: [String!]! - contacts: [CampaignContact] - contactsCount: Int - hasUnassignedContacts: Boolean - hasUnsentInitialMessages: Boolean - hasUnhandledMessages: Boolean + contacts: [CampaignContact!]! + contactsCount: Int! + hasUnassignedContacts: Boolean! + hasUnsentInitialMessages: Boolean! + hasUnhandledMessages: Boolean! hasSentMessages: Boolean! - customFields: [String] - customFieldAverageLengths: JSON + customFields: [String!]! + customFieldAverageLengths: JSON! cannedResponses(userId: String): [CannedResponse!]! - stats: CampaignStats, - pendingJobs(jobTypes: [String]): [JobRequest]! - datawarehouseAvailable: Boolean + stats: CampaignStats! + pendingJobs(jobTypes: [String]): [JobRequest!]! + datawarehouseAvailable: Boolean! introHtml: String primaryColor: String logoImageUrl: String - editors: String + editors: String! teams: [Team!]! campaignGroups: CampaignGroupPage campaignVariables: [CampaignVariable!]! - textingHoursStart: Int - textingHoursEnd: Int + textingHoursStart: Int! + textingHoursEnd: Int! isAutoassignEnabled: Boolean! repliesStaleAfter: Int isAssignmentLimitedToTeams: Boolean! - timezone: String + timezone: String! createdAt: String! previewUrl: String landlinesFiltered: Boolean! @@ -694,7 +689,6 @@ type Campaign { autosendStatus: String! messagingServiceSid: String autosendLimit: Int - columnMapping: [CsvColumnMapping!] messagingService: MessagingService contactsFilename: String } diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 9c7ac11c5..96728d15b 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -552,8 +552,7 @@ export const resolvers = { "createdAt", "landlinesFiltered", "messagingServiceSid", - "autosendLimit", - "columnMapping" + "autosendLimit" ]), isApproved: (campaign) => isNil(campaign.is_approved) ? false : campaign.is_approved, @@ -803,7 +802,7 @@ export const resolvers = { return ""; }, creator: async (campaign, _, { loaders }) => - campaign.creator_id ? loaders.user.load(campaign.creator_id) : null, + loaders.user.load(campaign.creator_id), previewUrl: async (campaign, _, { user, loaders }) => { const campaignId = campaign.id; const organizationId = await getCampaignOrganization({ campaignId }); diff --git a/src/server/api/lib/alerts.spec.ts b/src/server/api/lib/alerts.spec.ts index 7b17588f8..c04309b25 100644 --- a/src/server/api/lib/alerts.spec.ts +++ b/src/server/api/lib/alerts.spec.ts @@ -31,12 +31,13 @@ describe("notifyLargeCampaignEvent", () => { }); const setUpCampaign = async (client: PoolClient) => { - const { organization } = await createOrgAndSession(client, { + const { organization, user } = await createOrgAndSession(client, { agent, role: UserRoleType.OWNER }); const campaign = await createCampaign(client, { - organizationId: organization.id + organizationId: organization.id, + creatorId: user.id }); await Promise.all( [...new Array(10)].map(() => diff --git a/src/server/api/lib/autosend-initials.spec.ts b/src/server/api/lib/autosend-initials.spec.ts index 88f5685ec..78491220a 100644 --- a/src/server/api/lib/autosend-initials.spec.ts +++ b/src/server/api/lib/autosend-initials.spec.ts @@ -28,6 +28,7 @@ const createTestBed = async ( }); const campaign = await createCampaign(client, { organizationId: organization.id, + creatorId: user.id, isStarted: true }); await Promise.all( diff --git a/src/server/api/lib/campaign.spec.ts b/src/server/api/lib/campaign.spec.ts index 9f2bf3523..acf0093c2 100644 --- a/src/server/api/lib/campaign.spec.ts +++ b/src/server/api/lib/campaign.spec.ts @@ -230,6 +230,7 @@ describe("create / edit campaign", () => { const campaign = await createCampaign(client, { organizationId: result.organization.id, + creatorId: result.user.id, isStarted, isApproved }); @@ -502,7 +503,8 @@ describe("create / edit campaign", () => { }); const template = await createTemplate(client, { - organizationId: result.organization.id + organizationId: result.organization.id, + creatorId: result.user.id }); return { ...result, template }; }); diff --git a/src/server/tasks/handle-delivery-report.spec.ts b/src/server/tasks/handle-delivery-report.spec.ts index c252f1231..68bbd42b4 100644 --- a/src/server/tasks/handle-delivery-report.spec.ts +++ b/src/server/tasks/handle-delivery-report.spec.ts @@ -32,7 +32,8 @@ describe("handle-delivery-report", () => { const texter = await createTexter(client, {}); const organization = await createOrganization(client, {}); const campaign = await createCampaign(client, { - organizationId: organization.id + organizationId: organization.id, + creatorId: texter.id }); const campaignContact = await createCampaignContact(client, { campaignId: campaign.id diff --git a/src/server/tasks/queue-autosend-initials.spec.ts b/src/server/tasks/queue-autosend-initials.spec.ts index 8323ba8c2..c1b57f2a3 100644 --- a/src/server/tasks/queue-autosend-initials.spec.ts +++ b/src/server/tasks/queue-autosend-initials.spec.ts @@ -50,6 +50,7 @@ const setUpAutosending = async ( const campaign = await createCampaign(client, { organizationId, + creatorId: autosendUserId, isStarted: true, autosendUserId, autosendStatus