Skip to content
Open
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
3 changes: 3 additions & 0 deletions __test__/testbed-preparation/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
44 changes: 19 additions & 25 deletions libs/gql-schema/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,6 @@ export const schema = `
INCLUDES_NOT_ACTIVE_TARGETS
}

type CsvColumnMapping {
column: String!
remap: String!
}

type Campaign {
id: ID!
organization: Organization!
Expand All @@ -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!
Expand All @@ -126,7 +121,6 @@ export const schema = `
autosendStatus: String!
messagingServiceSid: String
autosendLimit: Int
columnMapping: [CsvColumnMapping!]
messagingService: MessagingService
contactsFilename: String
}
Expand Down
87 changes: 87 additions & 0 deletions migrations/20260816013052_campaign_not_null_columns.js
Original file line number Diff line number Diff line change
@@ -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 });
});
};
12 changes: 6 additions & 6 deletions schema-dump.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const AutosendingLimitField: React.FC<AutosendingLimitFieldProps> = ({
] = useUpdateCampaignAutosendingLimitMutation();

const countMessagedContacts = useMemo(
() => data?.campaign?.stats?.countMessagedContacts,
() => data?.campaign?.stats.countMessagedContacts,
[data]
);

Expand Down
22 changes: 11 additions & 11 deletions src/containers/AdminAutosending/components/AutosendingTargetRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ export const AutosendingTargetRow: React.FC<AutosendingTargetRowProps> = (
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 =
Expand All @@ -39,9 +45,7 @@ export const AutosendingTargetRow: React.FC<AutosendingTargetRowProps> = (
? 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 =
Expand Down Expand Up @@ -85,18 +89,14 @@ export const AutosendingTargetRow: React.FC<AutosendingTargetRowProps> = (
<TableCell>{target.contactsCount}</TableCell>
<TableCell>{target.deliverabilityStats.deliveredCount}</TableCell>
<TableCell>
{target.contactsCount! -
totalSent! -
(target.stats?.needsMessageOptOutsCount || 0)}
{target.contactsCount - totalSent - needsMessageOptOutsCount}
</TableCell>
<TableCell>{waitingToDeliver}</TableCell>
<TableCell>{target.deliverabilityStats.errorCount}</TableCell>
<TableCell>
<span style={{ color: repliesColor }}>
{target.stats?.receivedMessagesCount}
</span>
<span style={{ color: repliesColor }}>{receivedMessagesCount}</span>
</TableCell>
<TableCell>{target.stats?.optOutsCount}</TableCell>
<TableCell>{optOutsCount}</TableCell>
<TableCell>
<Link to={`/admin/${organizationId}/campaigns/${target.id}`}>
<MoreIcon />
Expand Down
4 changes: 0 additions & 4 deletions src/containers/AdminCampaignEdit/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,6 @@ export const EditCampaignFragment = gql`
}
messagingServiceSid
editors
columnMapping {
column
remap
}
readiness {
basics
textingHours
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,6 @@ const queries = {
id
}
datawarehouseAvailable
columnMapping {
column
remap
}
contactsFilename
}
}
Expand Down
7 changes: 3 additions & 4 deletions src/containers/AdminCampaignStats/components/TexterStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ export const TexterStats: React.FC<TexterStatsProps> = ({ campaignId }) => {
</tr>
</thead>
<tbody>
{assignments.map(
(assignment) =>
assignment && <TexterStatRow assignment={assignment} />
)}
{assignments.map((assignment) => (
<TexterStatRow key={assignment.id} assignment={assignment} />
))}
</tbody>
</table>
</Paper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,42 +61,39 @@ export const ManageSurveyResponses: React.FC<ManageSurveyResponsesProps> = (

useEffect(() => {
const { interactionSteps } = props.campaign;
const newQuestionResponses = interactionSteps?.reduce<QuestionResponseMap>(
const newQuestionResponses = interactionSteps.reduce<QuestionResponseMap>(
(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) => {
const { interactionSteps } = props.campaign;
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;
}
Expand Down Expand Up @@ -178,8 +175,8 @@ export const ManageSurveyResponses: React.FC<ManageSurveyResponsesProps> = (

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const TemplateCampaignRow: React.FC<TemplateCampaignRowProps> = ({
</span>
<br />
<span>
Created {createdAt} by {templateCampaign.creator?.displayName}
Created {createdAt} by {templateCampaign.creator.displayName}
</span>
</>
}
Expand Down
4 changes: 2 additions & 2 deletions src/containers/CampaignList/components/CampaignDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExternalSystem, "name" | "type"> | null | undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/containers/CampaignList/components/CampaignListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const CampaignListRow: React.FC<Props> = (props) => {

const classes = useStyles();

const creatorName = campaign.creator ? campaign.creator.displayName : null;
const creatorName = campaign.creator.displayName;

const isAutoAssignEligible = !!(
isStarted &&
Expand Down
Loading
Loading