diff --git a/__test__/backend.test.js b/__test__/backend.test.js deleted file mode 100644 index 82236fea4..000000000 --- a/__test__/backend.test.js +++ /dev/null @@ -1,415 +0,0 @@ -import { makeExecutableSchema } from '@graphql-tools/schema' -import { resolvers } from '../src/server/api/schema' -import { schema } from '../src/api/schema' -import { graphql } from 'graphql' -import { User, Organization, Campaign, CampaignContact, Assignment, r } from '../src/server/models/' -import { resolvers as campaignResolvers } from '../src/server/api/campaign' -import { getContext, - setupTest, - cleanupTest } from './test_helpers' - -const mySchema = makeExecutableSchema({ - typeDefs: schema, - resolvers: resolvers, - allowUndefinedInResolve: true, -}) - -const rootValue = {} - -// data items used across tests - -let testAdminUser -let testInvite -let testOrganization -let testCampaign -let testTexterUser - -// data creation functions - -async function createUser(userInfo = { - auth0_id: 'test123', - first_name: 'TestUserFirst', - last_name: 'TestUserLast', - cell: '555-555-5555', - email: 'testuser@example.com', -}) { - const user = new User(userInfo) - try { - await user.save() - console.log("created user") - console.log(user) - return user - } catch(err) { - console.error('Error saving user') - return false - } -} - -async function createContact(campaignId) { - const contact = new CampaignContact({ - first_name: "Ann", - last_name: "Lewis", - cell: "5555555555", - zip: "12345", - campaign_id: campaignId - }) - try { - await contact.save() - console.log("created contact") - console.log(contact) - return contact - } catch(err) { - console.error('Error saving contact: ', err) - return false - } -} - -async function createInvite() { - const inviteQuery = `mutation { - createInvite(invite: {is_valid: true}) { - id - } - }` - const context = getContext() - try { - const invite = await graphql(mySchema, inviteQuery, rootValue, context) - return invite - } catch(err) { - console.error('Error creating invite') - return false - } -} - -async function createOrganization(user, name, userId, inviteId) { - const context = getContext({ user }) - - const orgQuery = `mutation createOrganization($name: String!, $userId: String!, $inviteId: String!) { - createOrganization(name: $name, userId: $userId, inviteId: $inviteId) { - id - uuid - name - threeClickEnabled - textingHoursEnforced - textingHoursStart - textingHoursEnd - } - }` - - const variables = { - "userId": userId, - "name": name, - "inviteId": inviteId - } - - try { - const org = await graphql(mySchema, orgQuery, rootValue, context, variables) - return org - } catch(err) { - console.error('Error creating organization') - return false - } -} - -async function createCampaign(user, title, description, organizationId, contacts = []) { - const context = getContext({user}) - - const campaignQuery = `mutation createCampaign($input: CampaignInput!) { - createCampaign(campaign: $input) { - id - title - contacts { - firstName - lastName - } - } - }` - const variables = { - "input": { - "title": title, - "description": description, - "organizationId": organizationId, - "contacts": contacts - } - } - - try { - const campaign = await graphql(mySchema, campaignQuery, rootValue, context, variables) - return campaign - } catch(err) { - console.error('Error creating campaign') - return false - } -} - -// graphQL tests - -beforeAll(async () => await setupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) -afterAll(async () => await cleanupTest(), global.DATABASE_SETUP_TEARDOWN_TIMEOUT) - -it('should be undefined when user not logged in', async () => { - const query = `{ - currentUser { - id - } - }` - const context = getContext() - const result = await graphql(mySchema, query, rootValue, context) - const data = result - - expect(typeof data.currentUser).toEqual('undefined') -}) - -it('should return the current user when user is logged in', async () => { - testAdminUser = await createUser() - const query = `{ - currentUser { - email - } - }` - const context = getContext({ user: testAdminUser }) - const result = await graphql(mySchema, query, rootValue, context) - const { data } = result - - expect(data.currentUser.email).toBe('testuser@example.com') -}) - -// TESTING CAMPAIGN CREATION FROM END TO END - -it('should create an invite', async () => { - testInvite = await createInvite() - - expect(testInvite.data.createInvite.id).toBeTruthy() -}) - -it('should convert an invitation and user into a valid organization instance', async () => { - - if (testInvite && testAdminUser) { - console.log("user and invite for org") - console.log([testAdminUser,testInvite.data]) - - testOrganization = await createOrganization(testAdminUser, "Testy test organization", testInvite.data.createInvite.id, testInvite.data.createInvite.id) - - expect(testOrganization.data.createOrganization.name).toBe('Testy test organization') - } else { - console.log("Failed to create invite and/or user for organization test") - return false - } -}) - - -it('should create a test campaign', async () => { - const campaignTitle = "test campaign" - testCampaign = await createCampaign(testAdminUser, campaignTitle, "test description", testOrganization.data.createOrganization.id) - - expect(testCampaign.data.createCampaign.title).toBe(campaignTitle) -}) - -it('should create campaign contacts', async () => { - const contact = await createContact(testCampaign.data.createCampaign.id) - expect(contact.campaign_id).toBe(parseInt(testCampaign.data.createCampaign.id)) -}) - -it('should add texters to a organization', async () => { - testTexterUser = await createUser({ - auth0_id: 'test456', - first_name: 'TestTexterFirst', - last_name: 'TestTexterLast', - cell: '555-555-6666', - email: 'testtexter@example.com', - }) - const joinQuery = ` - mutation joinOrganization($organizationUuid: String!) { - joinOrganization(organizationUuid: $organizationUuid) { - id - } - }` - const variables = { - organizationUuid: testOrganization.data.createOrganization.uuid - } - const context = getContext({user: testTexterUser}) - const result = await graphql(mySchema, joinQuery, rootValue, context, variables) - expect(result.data.joinOrganization.id).toBeTruthy() -}) - -it('should assign texters to campaign contacts', async () => { - const campaignEditQuery = ` - mutation editCampaign($campaignId: String!, $campaign: CampaignInput!) { - editCampaign(id: $campaignId, campaign: $campaign) { - id - title - description - dueBy - isStarted - isArchived - contactsCount - datawarehouseAvailable - customFields - texters { - id - firstName - assignment(campaignId:$campaignId) { - contactsCount - needsMessageCount: contactsCount(contactsFilter:{messageStatus:\"needsMessage\"}) - } - } - interactionSteps { - id - questionText - script - answerOption - answerActions - parentInteractionId - isDeleted - } - cannedResponses { - id - title - text - } - } - }` - const context = getContext({user: testAdminUser}) - const updateCampaign = Object.assign({}, testCampaign.data.createCampaign) - const campaignId = updateCampaign.id - updateCampaign.texters = [{ - id: testTexterUser.id - }] - delete(updateCampaign.id) - delete(updateCampaign.contacts) - const variables = { - campaignId: campaignId, - campaign: updateCampaign - } - const result = await graphql(mySchema, campaignEditQuery, rootValue, context, variables) - expect(result.data.editCampaign.texters.length).toBe(1) - expect(result.data.editCampaign.texters[0].assignment.contactsCount).toBe(1) -}) - -// it('should save a campaign script composed of interaction steps', async() => {}) - -// it('should save some canned responses for texters', async() => {}) - -// it('should start the campaign', async() => {}) - -// TEST STUBS: MESSAGING - -// it('should send an inital message to test contacts', async() => {}) - -describe('Campaign', () => { - let organization - - beforeEach(async () => { - organization = await (new Organization({ - name: 'organization', - texting_hours_start: 0, - texting_hours_end: 0 - })).save() - }) - - describe('contacts', async () => { - let campaigns - let contacts - - beforeEach(async () => { - campaigns = await Promise.all([ - new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - }), - new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - }) - ].map(async (each) => ( - each.save() - ))) - - contacts = await Promise.all([ - new CampaignContact({campaign_id: campaigns[0].id, cell: '', message_status: 'closed'}), - new CampaignContact({campaign_id: campaigns[1].id, cell: '', message_status: 'closed'}) - ].map(async (each) => ( - each.save() - ))) - }) - - test('resolves contacts', async () => { - const results = await campaignResolvers.Campaign.contacts(campaigns[0]) - expect(results).toHaveLength(1) - expect(results[0].campaign_id).toEqual(campaigns[0].id) - }) - - test('resolves contacts count', async () => { - const results = await campaignResolvers.Campaign.contactsCount(campaigns[0]) - expect(results).toEqual(1) - }) - - test('resolves contacts count when empty', async () => { - const campaign = await (new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - })).save() - const results = await campaignResolvers.Campaign.contactsCount(campaign) - expect(results).toEqual(0) - }) - }) - - describe('unassigned contacts', () => { - let campaign - - beforeEach(async () => { - campaign = await (new Campaign({ - organization_id: organization.id, - is_started: false, - is_archived: false, - due_by: new Date() - })).save() - }) - - test('resolves unassigned contacts when true', async () => { - const contact = await (new CampaignContact({ - campaign_id: campaign.id, - message_status: 'closed', - cell: '', - })).save() - - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign) - expect(results).toEqual(true) - }) - - test('resolves unassigned contacts when false with assigned contacts', async () => { - const user = await (new User({ - auth0_id: 'test123', - first_name: 'TestUserFirst', - last_name: 'TestUserLast', - cell: '555-555-5555', - email: 'testuser@example.com', - })).save() - - const assignment = await (new Assignment({ - user_id: user.id, - campaign_id: campaign.id, - })).save() - - const contact = await (new CampaignContact({ - campaign_id: campaign.id, - assignment_id: assignment.id, - message_status: 'closed', - cell: '', - })).save() - - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign) - expect(results).toEqual(false) - }) - - test('resolves unassigned contacts when false with no contacts', async () => { - const results = await campaignResolvers.Campaign.hasUnassignedContacts(campaign) - expect(results).toEqual(false) - }) - }) -}) diff --git a/__test__/e2e/page-functions/campaigns.js b/__test__/e2e/page-functions/campaigns.js deleted file mode 100644 index 81f4062dd..000000000 --- a/__test__/e2e/page-functions/campaigns.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Date Picker Notes: - * The selector for the date is fragile. It may be better to programatically set it. - * await driver.executeScript('document.getElementsByName("dueBy")[0].setAttribute("value","10 Jan 2019")') - * Similarly, a sleep is added because it's difficult to know when the picker dialog is gone. - */ - -import _ from 'lodash' -import { wait, urlBuilder } from '../util/helpers' -import pom from '../page-objects/index' - -// For legibility -const form = pom.campaigns.form - -export const campaigns = { - startCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) - - it('clicks the + button to add a new campaign', async () => { - await wait.andClick(driver, pom.campaigns.add, { goesStale: true }) - }) - - it('completes the Basics section', async () => { - // Title - await wait.andType(driver, form.basics.title, campaign.basics.title) - // Description - await wait.andType(driver, form.basics.description, campaign.basics.description) - // Select a Due Date using the Date Picker - await wait.andClick(driver, form.basics.dueBy) - await wait.andClick(driver, form.datePickerDialog.nextMonth, { waitAfterVisible: 2000 }) - await wait.andClick(driver, form.datePickerDialog.enabledDate, { waitAfterVisible: 2000, goesStale: true }) - // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) - // This should switch to the Contacts section - expect(await wait.andGetEl(driver, form.contacts.uploadButton)).toBeDefined() - expect(await wait.andGetEl(driver, form.contacts.input, { elementIsVisible: false })).toBeDefined() - }) - - it('completes the Contacts section', async () => { - await wait.andType(driver, form.contacts.input, campaign.contacts.csv, { clear: false, click: false, elementIsVisible: false }) - expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() - // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000 }) - // Reload the Contacts section to validate Contacts - await wait.andClick(driver, form.contacts.section, { waitAfterVisible: 2000 }) - expect(await wait.andGetEl(driver, form.contacts.uploadedContacts)).toBeDefined() - expect(await wait.andGetEl(driver, form.contacts.uploadedContactsByQty(campaign.texters.contactLength))).toBeDefined() - await wait.andClick(driver, form.texters.section, { waitAfterVisible: 2000 }) - // This should switch to the Texters section - expect(await wait.andGetEl(driver, form.texters.addAll)).toBeDefined() - }) - - it('completes the Texters section', async () => { - if (campaign.existingTexter) { - // Add All - await wait.andClick(driver, form.texters.addAll) - // Assign (Split) - await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) - // Validate Assignment - const assignedToFirstTexter = await wait.andGetValue(driver, form.texters.texterAssignmentByIndex(0)) - expect(Number(assignedToFirstTexter)).toBeGreaterThan(0) - // Assign (All to Texter) - await wait.andClick(driver, form.texters.autoSplit, { elementIsVisible: false }) - await wait.andType(driver, form.texters.texterAssignmentByText(campaign.admin.given_name), '0') - await driver.sleep(1000) - await wait.andType(driver, form.texters.texterAssignmentByText(campaign.texter.given_name), campaign.texters.contactLength) - // Validate Assignment - expect(await wait.andGetValue(driver, form.texters.texterAssignmentByText(campaign.admin.given_name))).toBe('0') - } - // Save - await wait.andClick(driver, form.save) - // This should switch to the Interactions section - expect(await wait.andGetEl(driver, form.interactions.editorLaunch)).toBeDefined() - }) - - describe('completes the Interactions section', () => { - it('adds an initial question', async () => { - // Script - await wait.andClick(driver, form.interactions.editorLaunch) - await wait.andType(driver, pom.scriptEditor.editor, campaign.interaction.script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) - // Question - await wait.andType(driver, form.interactions.questionText, campaign.interaction.question, { waitAfterVisible: 2000 }) - // Save with No Answers Defined - await wait.andClick(driver, form.interactions.submit) - await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) - let allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(0) - // Save with Empty Answer - await wait.andClick(driver, form.interactions.addResponse) - await wait.andClick(driver, form.interactions.submit) - await wait.andClick(driver, form.interactions.section, { waitAfterVisible: 2000 }) - allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(1) - }) - - describe('Add all Responses', () => { - _.each(campaign.interaction.answers, (answer, index) => { - it(`Adds Answer ${index}`, async () => { - if (index > 0) await wait.andClick(driver, form.interactions.addResponse) // The first (0th) response reuses the empty Answer created above - // Answer - await wait.andType(driver, form.interactions.answerOptionChildByIndex(index), answer.answerOption, { clear: false, waitAfterVisible: 2000 }) - // Answer Script - await wait.andClick(driver, form.interactions.editorLaunchChildByIndex(index)) - await wait.andType(driver, pom.scriptEditor.editor, answer.script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) - // Answer - Next Question - await wait.andType(driver, form.interactions.questionTextChildByIndex(index), answer.questionText, { clear: false, waitAfterVisible: 2000 }) - }) - }) - it('validates that all responses were added', async () => { - const allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(campaign.interaction.answers.length) - }) - }) - - it('saves for the last time', async () => { - // Save - await wait.andClick(driver, form.interactions.submit) - // This should switch to the Canned Responses section - expect(await wait.andGetEl(driver, form.cannedResponse.addNew)).toBeDefined() - }) - }) - - it('completes the Canned Responses section', async () => { - // Add New - await wait.andClick(driver, form.cannedResponse.addNew) - // Title - await wait.andType(driver, form.cannedResponse.title, campaign.cannedResponses[0].title) - // Script - await wait.andClick(driver, form.cannedResponse.editorLaunch) - await wait.andType(driver, pom.scriptEditor.editor, campaign.cannedResponses[0].script, { clear: false, click: false, waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.done, { goesStale: true }) - // Script - Relaunch and cancel (bug?) - await wait.andClick(driver, form.cannedResponse.editorLaunch, { waitAfterVisible: 2000 }) - await wait.andClick(driver, pom.scriptEditor.cancel, { waitAfterVisible: 2000, goesStale: true }) - // Submit Response - await wait.andClick(driver, form.cannedResponse.submit, { waitAfterVisible: 2000, goesStale: true }) - // Save - await wait.andClick(driver, form.save, { waitAfterVisible: 2000, goesStale: true }) - // Should be able to start campaign - expect(await wait.andIsEnabled(driver, pom.campaigns.start)).toBeTruthy() - }) - - it('clicks Start Campaign', async () => { - // Store the new campaign URL into a global for future use. - global.e2e.newCampaignUrl = await driver.getCurrentUrl() - await wait.andClick(driver, pom.campaigns.start, { waitAfterVisible: 2000, goesStale: true }) - // Validate Started - expect(await wait.andGetEl(driver, pom.campaigns.isStarted)).toBeTruthy() - }) - }, - copyCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) - - it('clicks on an existing campaign', async () => { - await wait.andClick(driver, pom.campaigns.campaignRowByText(campaign.basics.title), { goesStale: true }) - }) - - it('clicks Copy in Stats', async () => { - await wait.andClick(driver, pom.campaigns.stats.copy, { waitAfterVisible: 2000 }) - }) - - it('verifies copy in Campaigns list', async () => { - await wait.andClick(driver, pom.navigation.sections.campaigns) - expect(await wait.andGetEl(driver, pom.campaigns.campaignRowByText('COPY'))).toBeDefined() - // expect(await wait.andGetEl(driver, pom.campaigns.warningIcon)).toBeDefined() - await wait.andClick(driver, pom.campaigns.campaignRowByText('COPY')) - }) - - describe('verifies Campaign sections', () => { - it('verifies Basics section', async () => { - await wait.andClick(driver, form.basics.section) - expect(await wait.andGetValue(driver, form.basics.title)).toBe(campaign.basics.title_copied) - expect(await wait.andGetValue(driver, form.basics.description)).toBe(campaign.basics.description) - expect(await wait.andGetValue(driver, form.basics.dueBy)).toBe('') - }) - it('verifies Contacts section', async () => { - await wait.andClick(driver, form.contacts.section) - const uploadedContacts = await driver.findElements(form.contacts.uploadedContacts) - expect(uploadedContacts.length > 0).toBeFalsy() - }) - it('verifies Texters section', async () => { - await wait.andClick(driver, form.texters.section) - const assignedContacts = await driver.findElements(form.texters.texterAssignmentByText(campaign.texter.given_name)) - expect(assignedContacts.length > 0).toBeFalsy() - }) - it('verifies Interactions section', async () => { - await wait.andClick(driver, form.interactions.section) - expect(await wait.andGetValue(driver, form.interactions.editorLaunch)).toBe(campaign.interaction.script) - expect(await wait.andGetValue(driver, form.interactions.questionText)).toBe(campaign.interaction.question) - // Verify Answers - const allChildInteractions = await driver.findElements(form.interactions.childInteraction) - expect(allChildInteractions.length).toBe(campaign.interaction.answers.length) - }) - it('verifies Canned Responses section', async () => { - await wait.andClick(driver, form.cannedResponse.section) - expect(await wait.andGetEl(driver, form.cannedResponse.createdResponseByText(campaign.cannedResponses[0].title))).toBeDefined() - expect(await wait.andGetEl(driver, form.cannedResponse.createdResponseByText(campaign.cannedResponses[0].script))).toBeDefined() - }) - }) - }, - editCampaign(driver, campaign) { - it('opens the Campaigns tab', async () => { - await driver.get(urlBuilder.admin.root()) - await wait.andClick(driver, pom.navigation.sections.campaigns) - }) - - it('clicks on an existing campaign', async () => { - await wait.andClick(driver, pom.campaigns.campaignRowByText(campaign.basics.title), { goesStale: true }) - }) - - it('clicks edit in Stats', async () => { - await wait.andClick(driver, pom.campaigns.stats.edit, { waitAfterVisible: 2000, goesStale: true }) - }) - - it('changes the title in the Basics section', async () => { - // Expand Basics section - await wait.andClick(driver, form.basics.section) - // Change Title - await wait.andType(driver, form.basics.title, campaign.basics.title_changed, { clear: false }) - // Save - await wait.andClick(driver, form.save) - }) - - it('reopens the Basics section to verify title', async () => { - // Expand Basics section - await wait.andClick(driver, form.basics.section, { waitAfterVisible: 2000 }) - // Verify Title - expect(await wait.andGetValue(driver, form.basics.title)).toBe(campaign.basics.title_changed) - }) - }, - sendReplies(driver, campaign) { - it('sends Replies', async () => { - const sendRepliesUrl = global.e2e.newCampaignUrl.substring(0, global.e2e.newCampaignUrl.indexOf('edit?new=true')) + 'send-replies' - await driver.get(sendRepliesUrl) - }) - describe('simulates the assigned contacts sending replies', () => { - _.times(campaign.texters.contactLength, n => { - it(`sends reply ${n}`, async () => { - await wait.andType(driver, pom.campaigns.replyByIndex(n), campaign.standardReply) - await wait.andClick(driver, pom.campaigns.sendByIndex(n)) - }) - }) - }) - } -} diff --git a/__test__/e2e/page-objects/campaigns.js b/__test__/e2e/page-objects/campaigns.js index edbb7d450..6352aa232 100644 --- a/__test__/e2e/page-objects/campaigns.js +++ b/__test__/e2e/page-objects/campaigns.js @@ -11,8 +11,7 @@ export const campaigns = { basics: { section: By.css('[data-test=basics]'), title: By.css('[data-test=title]'), - description: By.css('[data-test=description]'), - dueBy: By.css('[data-test=dueBy]') + description: By.css('[data-test=description]') }, datePickerDialog: { // This selector is fragile and alternate means of finding an enabled date should be investigated. diff --git a/__test__/server/api/assignment.test.js b/__test__/server/api/assignment.test.js deleted file mode 100644 index cf45e332d..000000000 --- a/__test__/server/api/assignment.test.js +++ /dev/null @@ -1,183 +0,0 @@ -import { getContacts } from '../../../src/server/api/assignment' -import { Organization, Assignment, Campaign } from '../../../src/server/models' - -jest.mock('../../../src/lib/timezones.js') -var timezones = require('../../../src/lib/timezones.js') - -describe('test getContacts builds queries correctly', () => { - var organization = new Organization({ - texting_hours_enforced: false, - texting_hours_start: 9, - texting_hours_end: 14 - }) - - var campaign = new Campaign({ - due_by: new Date() - }) - - const past_due_campaign = new Campaign({ - due_by: new Date().setFullYear(new Date().getFullYear() - 1) - }) - - var assignment = new Assignment({ - id: 1 - }) - - beforeEach(() => { - timezones.getOffsets.mockReturnValueOnce([['-5_1'], ['-4_1']]) - }) - - afterAll(() => { - jest.restoreAllMocks() - }) - - it('works with: no contacts filter', () => { - const query = getContacts(assignment, undefined, organization, campaign) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 order by message_status DESC, updated_at" - ) - }) // it - - it('works with: contacts filter, include past due, message status', () => { - const query = getContacts(assignment, { includePastDue: true }, organization, campaign) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('works with: contacts filter, exclude past due, message status needsMessageOrResponse', () => { - const query = getContacts( - assignment, - { messageStatus: 'needsMessageOrResponse' }, - organization, - campaign - ) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('works with: contacts filter, exclude past due, campaign is past due, message status needsMessage', () => { - const query = getContacts( - assignment, - { messageStatus: 'needsMessage' }, - organization, - past_due_campaign - ) - // this should be empty because the query is empty and thus we return [] - expect(query.toString()).toBe('') - }) // it - - it('works with: contacts filter, exclude past due, message status one other', () => { - const query = getContacts(assignment, { messageStatus: 'convo' }, organization, campaign) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('convo') order by message_status DESC, updated_at DESC" - ) - }) // it - - it('works with: contacts filter, exclude past due, message status multiple other', () => { - const query = getContacts( - assignment, - { messageStatus: 'convo,messageReceived' }, - organization, - campaign - ) - expect(query.toString()).toBe( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"message_status\" in ('convo', 'messageReceived') order by message_status DESC, updated_at" - ) - }) // it - - it('works with: contacts filter, exclude past due, no message status, campaign is past due', () => { - const query = getContacts(assignment, {}, organization, past_due_campaign) - expect(query.toString()).toBe( - 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\') order by message_status DESC, updated_at' - ) - }) // it - - it('works with: contacts filter, exclude past due, no message status, campaign not past due', () => { - const query = getContacts(assignment, {}, organization, campaign) - expect(query.toString()).toBe( - 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\') order by message_status DESC, updated_at' - ) - }) // it - - it('works with: forCount, contacts filter, exclude past due, no message status, campaign not past due', () => { - const query = getContacts(assignment, {}, organization, campaign, true) - expect(query.toString()).toBe( - 'select * from "campaign_contact" where "assignment_id" = 1 and "message_status" in (\'needsResponse\', \'needsMessage\')' - ) - }) // it -}) // describe - -describe('test getContacts timezone stuff only', () => { - var organization = new Organization({ - texting_hours_enforced: true, - texting_hours_start: 9, - texting_hours_end: 14 - }) - - var campaign = new Campaign({ - due_by: new Date() - }) - - var assignment = new Assignment({ - id: 1 - }) - - beforeEach(() => { - timezones.getOffsets.mockReturnValueOnce([['-5_1'], ['-4_1']]) - }) - - afterAll(() => { - jest.restoreAllMocks() - }) - - it('returns the correct query -- in default texting hours, with valid_timezone == true', () => { - var query = getContacts(assignment, { validTimezone: true }, organization, campaign) - expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-5_1', '') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('returns the correct query -- in default texting hours, with valid_timezone == false', () => { - var query = getContacts(assignment, { validTimezone: false }, organization, campaign) - expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-4_1') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('returns the correct query -- NOT in default texting hours, with valid_timezone == true', () => { - var query = getContacts(assignment, { validTimezone: true }, organization, campaign) - expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-5_1') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('returns the correct query -- NOT in default texting hours, with valid_timezone == false', () => { - var query = getContacts(assignment, { validTimezone: false }, organization, campaign) - expect(query.toString()).toMatch( - "select * from \"campaign_contact\" where \"assignment_id\" = 1 and \"timezone_offset\" in ('-4_1', '') and \"message_status\" in ('needsResponse', 'needsMessage') order by message_status DESC, updated_at" - ) - }) // it - - it('returns the correct query -- no contacts filter', () => { - var query = getContacts(assignment, null, organization, campaign) - expect(query.toString()).toMatch( - /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it - - it('returns the correct query -- no validTimezone property in contacts filter', () => { - var query = getContacts(assignment, {}, organization, campaign) - expect(query.toString()).toMatch( - /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it - - it('returns the correct query -- validTimezone property is null', () => { - var query = getContacts(assignment, { validTimezone: null }, organization, campaign) - expect(query.toString()).toMatch( - /^select \* from \"campaign_contact\" where \"assignment_id\" = 1.*/ - ) - }) // it -}) // describe diff --git a/__test__/server/db/init_schemas/campaign.json b/__test__/server/db/init_schemas/campaign.json index 900639bfe..d3a9ff8f4 100644 --- a/__test__/server/db/init_schemas/campaign.json +++ b/__test__/server/db/init_schemas/campaign.json @@ -29,12 +29,6 @@ "nullable": true, "defaultValue": null }, - "due_by": { - "type": "timestamp with time zone", - "maxLength": null, - "nullable": true, - "defaultValue": null - }, "created_at": { "type": "timestamp with time zone", "maxLength": null, diff --git a/dev-tools/clone_campaign_sproc.sql b/dev-tools/clone_campaign_sproc.sql index f269ed7d4..ec521a6c3 100644 --- a/dev-tools/clone_campaign_sproc.sql +++ b/dev-tools/clone_campaign_sproc.sql @@ -30,7 +30,6 @@ begin is_approved, is_started, is_archived, - due_by, logo_image_url, intro_html, primary_color, @@ -54,7 +53,6 @@ begin false as is_approved, false as is_started, false as is_archived, - due_by, logo_image_url, intro_html, primary_color, diff --git a/dev-tools/export-all-campaigns.js b/dev-tools/export-all-campaigns.js index 18bd645f4..fb59a6be7 100644 --- a/dev-tools/export-all-campaigns.js +++ b/dev-tools/export-all-campaigns.js @@ -29,13 +29,7 @@ const main = async (lastId) => { ); const campaignIds = await r .knex("campaign") - .where((builder) => - builder - .where({ is_archived: false }) - .orWhereRaw( - `due_by > now() - interval '${ARCHIVED_CAMPAIGN_AGE_DAYS} day'` - ) - ) + .where((builder) => builder.where({ is_archived: false })) .where("id", ">", lastId) .limit(CAMPAIGN_FETCH_CHUNK_SIZE) .orderBy("id") diff --git a/libs/gql-schema/campaign.ts b/libs/gql-schema/campaign.ts index 972434a9d..8991f54bc 100644 --- a/libs/gql-schema/campaign.ts +++ b/libs/gql-schema/campaign.ts @@ -78,7 +78,6 @@ export const schema = ` organization: Organization! title: String! description: String! - dueBy: Date readiness: CampaignReadiness! isApproved: Boolean! isStarted: Boolean @@ -171,7 +170,6 @@ export const schema = ` input CampaignInput { title: String description: String - dueBy: Date logoImageUrl: String primaryColor: String introHtml: String diff --git a/libs/spoke-codegen/src/graphql/campaign-list.graphql b/libs/spoke-codegen/src/graphql/campaign-list.graphql index 08ffdf380..fd898708e 100644 --- a/libs/spoke-codegen/src/graphql/campaign-list.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-list.graphql @@ -9,7 +9,6 @@ fragment CampaignListEntry on Campaign { hasUnsentInitialMessages hasUnhandledMessages description - dueBy creator { displayName } diff --git a/libs/spoke-codegen/src/graphql/campaign-operations.graphql b/libs/spoke-codegen/src/graphql/campaign-operations.graphql index 71541de35..f518f9e99 100644 --- a/libs/spoke-codegen/src/graphql/campaign-operations.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-operations.graphql @@ -19,7 +19,6 @@ fragment CampaignInfo on Campaign { hasUnsentInitialMessages hasUnhandledMessages description - dueBy } query GetAdminAssignmentTargets($organizationId: String!) { diff --git a/libs/spoke-codegen/src/graphql/campaign-stats.graphql b/libs/spoke-codegen/src/graphql/campaign-stats.graphql index a621e7ec4..8acda0959 100644 --- a/libs/spoke-codegen/src/graphql/campaign-stats.graphql +++ b/libs/spoke-codegen/src/graphql/campaign-stats.graphql @@ -18,7 +18,6 @@ query getCampaign($campaignId: String!) { campaign(id: $campaignId) { id title - dueBy isArchived isStarted previewUrl diff --git a/libs/spoke-codegen/src/pg-types.ts b/libs/spoke-codegen/src/pg-types.ts index 033cbabf6..559acb38f 100644 --- a/libs/spoke-codegen/src/pg-types.ts +++ b/libs/spoke-codegen/src/pg-types.ts @@ -130,7 +130,6 @@ export interface campaign { title: string description: string is_started?: boolean | null - due_by?: Date | null created_at: Date is_archived?: boolean | null logo_image_url?: string | null @@ -208,7 +207,6 @@ export interface campaign_with_groups { title?: string | null description?: string | null is_started?: boolean | null - due_by?: Date | null created_at?: Date | null is_archived?: boolean | null logo_image_url?: string | null diff --git a/migrations/20260505000000_drop_due_by.js b/migrations/20260505000000_drop_due_by.js new file mode 100644 index 000000000..bd3677481 --- /dev/null +++ b/migrations/20260505000000_drop_due_by.js @@ -0,0 +1,524 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function up(knex) { + return knex.schema.raw(` + drop view campaign cascade; + + alter table all_campaign drop column due_by; + + create or replace view campaign as + select + id, + organization_id, + title, + description, + is_started, + 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_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_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 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 ( -- assignable contacts are valid for both autoassign and autosending + select 1 + from assignable_needs_message + where campaign_id = sendable_campaigns.id + ) + and autosend_status = 'sending' + ); + `); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function down(knex) { + return knex.schema.raw(` + drop view campaign cascade; + + alter table all_campaign add column due_by timestamp default null; + + 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_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_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 ( -- assignable contacts are valid for both autoassign and autosending + 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..bc70f0584 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -201,7 +201,6 @@ CREATE TABLE public.all_campaign ( title text DEFAULT ''::text NOT NULL, description text DEFAULT ''::text NOT NULL, is_started boolean, - due_by timestamp with time zone, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, is_archived boolean, logo_image_url text, @@ -1504,7 +1503,6 @@ CREATE VIEW public.campaign AS all_campaign.title, all_campaign.description, all_campaign.is_started, - all_campaign.due_by, all_campaign.created_at, all_campaign.is_archived, all_campaign.logo_image_url, @@ -1702,9 +1700,7 @@ CREATE VIEW public.assignable_campaigns_with_needs_message AS FROM public.assignable_campaigns WHERE ((EXISTS ( SELECT 1 FROM public.assignable_needs_message - WHERE (assignable_needs_message.campaign_id = assignable_campaigns.id))) AND (NOT (EXISTS ( SELECT 1 - FROM public.campaign - WHERE ((campaign.id = assignable_campaigns.id) AND (now() > date_trunc('day'::text, ((campaign.due_by + '24:00:00'::interval) AT TIME ZONE campaign.timezone))))))) AND (assignable_campaigns.autosend_status <> 'sending'::text)); + WHERE (assignable_needs_message.campaign_id = assignable_campaigns.id))) AND (assignable_campaigns.autosend_status <> 'sending'::text)); ALTER TABLE public.assignable_campaigns_with_needs_message OWNER TO postgres; @@ -1853,9 +1849,7 @@ CREATE VIEW public.autosend_campaigns_to_send AS FROM public.sendable_campaigns WHERE ((EXISTS ( SELECT 1 FROM public.assignable_needs_message - WHERE (assignable_needs_message.campaign_id = sendable_campaigns.id))) AND (NOT (EXISTS ( SELECT 1 - FROM public.campaign - WHERE ((campaign.id = sendable_campaigns.id) AND (now() > date_trunc('day'::text, ((campaign.due_by + '24:00:00'::interval) AT TIME ZONE campaign.timezone))))))) AND (sendable_campaigns.autosend_status = 'sending'::text)); + WHERE (assignable_needs_message.campaign_id = sendable_campaigns.id))) AND (sendable_campaigns.autosend_status = 'sending'::text)); ALTER TABLE public.autosend_campaigns_to_send OWNER TO postgres; diff --git a/seeds/staging/04-campaigns.csv b/seeds/staging/04-campaigns.csv index e83d8d8a9..177f3736a 100644 --- a/seeds/staging/04-campaigns.csv +++ b/seeds/staging/04-campaigns.csv @@ -1,3 +1,3 @@ -id,organization_id,title,description,is_started,is_archived,is_approved,due_by,texting_hours_start,texting_hours_end,timezone,is_autoassign_enabled,creator_id -1,1,Spring Outreach Test,A test campaign for staging QA,true,false,true,,9,21,US/Eastern,true,1 -2,1,Winter 2025 Completed,An archived campaign for list variety,true,true,true,,9,21,US/Eastern,false,1 +id,organization_id,title,description,is_started,is_archived,is_approved,texting_hours_start,texting_hours_end,timezone,is_autoassign_enabled,creator_id +1,1,Spring Outreach Test,A test campaign for staging QA,true,false,true,9,21,US/Eastern,true,1 +2,1,Winter 2025 Completed,An archived campaign for list variety,true,true,true,9,21,US/Eastern,false,1 diff --git a/src/containers/AdminCampaignEdit/index.jsx b/src/containers/AdminCampaignEdit/index.jsx index 45490579b..afc08fb7e 100644 --- a/src/containers/AdminCampaignEdit/index.jsx +++ b/src/containers/AdminCampaignEdit/index.jsx @@ -1,6 +1,5 @@ import Button from "@material-ui/core/Button"; import CircularProgress from "@material-ui/core/CircularProgress"; -import { red } from "@material-ui/core/colors"; import Dialog from "@material-ui/core/Dialog"; import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; @@ -24,7 +23,6 @@ import { compose } from "recompose"; import { withSpokeContext } from "../../client/spoke-context"; import CampaignNavigation from "../../components/CampaignNavigation"; import { dataTest } from "../../lib/attributes"; -import { DateTime } from "../../lib/datetime"; import theme from "../../styles/theme"; import { withAuthzContext } from "../AuthzProvider"; import { loadData } from "../hoc/with-operations"; @@ -312,7 +310,6 @@ class AdminCampaignEdit extends React.Component { keys: [ "title", "description", - "dueBy", "logoImageUrl", "primaryColor", "introHtml" @@ -667,21 +664,17 @@ class AdminCampaignEdit extends React.Component { renderHeader = () => { const { - campaign: { dueBy, isStarted, title, isTemplate } = {} + campaign: { isStarted, title, isTemplate } = {} } = this.props.campaignData; - const isOverdue = DateTime.local() >= DateTime.fromISO(dueBy); - const notStarting = isStarted ? (
- {isOverdue - ? "This campaign is running but is overdue!" - : "This campaign is running!"} + This campaign is running! {this.renderCurrentEditors()}
) : ( diff --git a/src/containers/AdminCampaignEdit/queries.ts b/src/containers/AdminCampaignEdit/queries.ts index 62a435ee2..346894699 100644 --- a/src/containers/AdminCampaignEdit/queries.ts +++ b/src/containers/AdminCampaignEdit/queries.ts @@ -73,7 +73,6 @@ export const EditCampaignFragment = gql` id title description - dueBy isStarted isApproved isArchived diff --git a/src/containers/AdminCampaignEdit/sections/CampaignBasicsForm.tsx b/src/containers/AdminCampaignEdit/sections/CampaignBasicsForm.tsx index 1db302caf..55b36ac02 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignBasicsForm.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignBasicsForm.tsx @@ -23,7 +23,6 @@ import { asSection } from "../components/SectionWrapper"; interface BasicsValues { title?: string; description?: string; - dueBy?: string | null; logoImageUrl?: string; primaryColor?: string; introHtml?: string; @@ -52,7 +51,6 @@ const schemaForIsStarted = (mustBeComplete: boolean) => yup.object({ title: mustBeComplete ? yup.string().required() : yup.string(), description: mustBeComplete ? yup.string().required() : yup.string(), - dueBy: yup.mixed().nullable(), logoImageUrl: yup .string() .url() @@ -174,7 +172,6 @@ const queries = { id title description - dueBy introHtml logoImageUrl primaryColor @@ -202,7 +199,6 @@ const mutations = { id title description - dueBy introHtml logoImageUrl primaryColor diff --git a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx index 1e51fa085..d4d6208da 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignTextersForm/index.tsx @@ -2,19 +2,15 @@ import type { ApolloQueryResult } from "@apollo/client"; import type { WithApolloClient } from "@apollo/client/react/hoc"; import { withApollo } from "@apollo/client/react/hoc"; import Button from "@material-ui/core/Button"; -import { red } from "@material-ui/core/colors"; import IconButton from "@material-ui/core/IconButton"; import Snackbar from "@material-ui/core/Snackbar"; import CloseIcon from "@material-ui/icons/Close"; +import type { Campaign, TexterAssignmentInput } from "@spoke/spoke-codegen"; import { css, StyleSheet } from "aphrodite"; import orderBy from "lodash/orderBy"; import React, { useState } from "react"; import { compose } from "recompose"; -import type { TexterAssignmentInput } from "../../../../api/assignment"; -import type { Campaign } from "../../../../api/campaign"; -import type { User } from "../../../../api/user"; -import { DateTime } from "../../../../lib/datetime"; import type { MutationMap, QueryMap } from "../../../../network/types"; import theme from "../../../../styles/theme"; import { loadData } from "../../../hoc/with-operations"; @@ -58,7 +54,7 @@ const inlineStyles = { type CampaignWithTexter = Pick< Campaign, - "id" | "isStarted" | "dueBy" | "contactsCount" + "id" | "isStarted" | "contactsCount" > & { texters: Texter[]; }; @@ -79,7 +75,7 @@ interface HocProps extends WithApolloClient { organizationData: { organization: { id: string; - texters: Pick[]; + texters: OrgTexter[]; }; }; } @@ -114,7 +110,7 @@ const CampaignTextersForm: React.FC = (props) => { organization: { texters: orgTexters } }, campaignData: { - campaign: { contactsCount, texters, dueBy } + campaign: { contactsCount, texters } } } = props; @@ -122,6 +118,7 @@ const CampaignTextersForm: React.FC = (props) => { ({ assignment }) => assignment.contactsCount > 0 ); + if (!contactsCount) return null; const { lastReset, autoSplit, @@ -138,7 +135,7 @@ const CampaignTextersForm: React.FC = (props) => { const resetJobsOrTexters = async () => { // Check for new pending jobs -- if there is one, asSection will rerender CampaignTextersForm // when the job completes, resetting the reducer state - const response = await client.query({ + const response = await client?.query({ query: GET_CAMPAIGN_JOBS_QUERY, variables: { campaignId, jobTypes: JOB_QUEUE_NAMES }, fetchPolicy: "network-only" @@ -146,12 +143,14 @@ const CampaignTextersForm: React.FC = (props) => { // If there is no pending job then the assignment job completed _much_ faster than usual and // we need to refresh and reset texters state ourselves + if (!response) return null; const { pendingJobs } = response.data.campaign; + if (pendingJobs.length === 0) { const { data: { campaign } } = await props.campaignData.refetch(); - reset(campaign.texters, campaign.contactsCount); + reset(campaign.texters, contactsCount); } }; @@ -166,10 +165,9 @@ const CampaignTextersForm: React.FC = (props) => { }; // Campaign stuff - const isOverdue = dueBy ? DateTime.local() >= DateTime.fromISO(dueBy) : false; const shouldShowTextersManager = orgTexters.length > 0; const finalSaveLabel = working ? "Working..." : saveLabel; - const finalSaveDisabled = isOverdue || working || saveDisabled; + const finalSaveDisabled = working || saveDisabled; const handleAddTexters = (newTexters: OrgTexter[]) => addTexters(newTexters); @@ -198,7 +196,7 @@ const CampaignTextersForm: React.FC = (props) => { if (response.errors) throw response.errors; // Force refetch of pending jobs for _this_ campaign and section -- refetchQueries wasn't doing the trick await resetJobsOrTexters(); - } catch (err) { + } catch (err: any) { props.onError(err.message); } finally { setWorking(false); @@ -209,17 +207,7 @@ const CampaignTextersForm: React.FC = (props) => { return ( <> - - This campaign is overdue! Please change the due date before - editing Texters - - ) - } - /> + {shouldShowTextersManager && ( = DateTime.fromISO(campaign.dueBy); const newTitle = `${organization.name} - Campaigns - ${campaignId}: ${campaign.title}`; // only a superadmin with multiple active orgs can copy a campaign to another org @@ -323,10 +316,6 @@ class AdminCampaignStats extends React.Component { > Campaign ID: {campaign.id}
- Due:{" "} - - {dueFormatted} {isOverdue && "(Overdue)"} - {showMessagingServiceName && ( <>
diff --git a/src/containers/CampaignList/components/CampaignListRow.tsx b/src/containers/CampaignList/components/CampaignListRow.tsx index 01147ed66..2c68c7bba 100644 --- a/src/containers/CampaignList/components/CampaignListRow.tsx +++ b/src/containers/CampaignList/components/CampaignListRow.tsx @@ -12,7 +12,6 @@ import React from "react"; import { useHistory } from "react-router-dom"; import { dataTest } from "../../../lib/attributes"; -import { DateTime } from "../../../lib/datetime"; import type { CampaignOperationsProps } from "../utils"; import CampaignListMenu from "./CampaignListMenu"; @@ -72,16 +71,8 @@ export const CampaignListRow: React.FC = (props) => { color: theme.palette.success.dark }; } - const dueBy = DateTime.fromISO(campaign.dueBy || ""); const creatorName = campaign.creator ? campaign.creator.displayName : null; let tags = []; - if (DateTime.local() >= dueBy) { - tags.push({ - title: "Overdue", - color: theme.palette.grey[900], - backgroundColor: theme.palette.error.main - }); - } if (externalSystem) { const title = `${externalSystem.type}: ${externalSystem.name}`; @@ -124,7 +115,6 @@ export const CampaignListRow: React.FC = (props) => { label={tag.title} className={styles.chip} style={{ - color: tag.color, backgroundColor: tag.backgroundColor }} /> @@ -139,7 +129,6 @@ export const CampaignListRow: React.FC = (props) => { {campaign.description} {creatorName ? — Created by {creatorName} : null}
- {dueBy.isValid ? dueBy.toFormat("DD") : "No due date set"} ); diff --git a/src/containers/TexterTodoList/components/AssignmentSummary.tsx b/src/containers/TexterTodoList/components/AssignmentSummary.tsx index 30e4fdfc1..4086cdf73 100644 --- a/src/containers/TexterTodoList/components/AssignmentSummary.tsx +++ b/src/containers/TexterTodoList/components/AssignmentSummary.tsx @@ -3,12 +3,11 @@ import Card from "@material-ui/core/Card"; import CardActions from "@material-ui/core/CardActions"; import CardHeader from "@material-ui/core/CardHeader"; import Divider from "@material-ui/core/Divider"; -import type { Assignment } from "@spoke/spoke-codegen "; +import type { Assignment } from "@spoke/spoke-codegen"; import React from "react"; import { useHistory } from "react-router-dom"; import { useSpokeContext } from "../../../client/spoke-context"; -import { DateTime } from "../../../lib/datetime"; import type { MessageType } from "./BadgeButton"; import BadgeButton from "./BadgeButton"; @@ -105,15 +104,10 @@ export const AssignmentSummary: React.FC = (props) => { const { title, description, - dueBy, primaryColor = context.theme?.defaultCampaignColor, logoImageUrl = context.theme?.defaultCampaignLogo, introHtml } = assignment.campaign; - const dueByText = dueBy - ? DateTime.fromISO(dueBy).toFormat("MMM d, yyyy") - : "No Due Date"; - const subtitle = `${description} - ${dueByText}`; const classes = useStyles(); @@ -122,7 +116,7 @@ export const AssignmentSummary: React.FC = (props) => { { @@ -53,23 +52,6 @@ export const getContacts = ( campaign, forCount = false ) => { - // 24-hours past due - why is this 24 hours offset? - const includePastDue = contactsFilter && contactsFilter.includePastDue; - - const dueBy = DateTime.fromJSDate(new Date(campaign.due_by)); - const pastDue = campaign.due_by - ? dueBy.plus({ days: 1 }) < DateTime.local() - : false; - - if ( - !includePastDue && - pastDue && - contactsFilter && - contactsFilter.messageStatus === "needsMessage" - ) { - return []; - } - let query = r .reader("campaign_contact") .where({ @@ -108,14 +90,10 @@ export const getContacts = ( } } - query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( + query = addWhereClauseForContactsFilterMessageStatus( query, (contactsFilter && contactsFilter.messageStatus) || - (pastDue - ? // by default if asking for 'send later' contacts we include only those that need replies - "needsResponse" - : // we do not want to return closed/messaged - "needsMessageOrResponse") + "needsMessageOrResponse" ); if (Object.prototype.hasOwnProperty.call(contactsFilter, "isOptedOut")) { diff --git a/src/server/api/campaign.js b/src/server/api/campaign.js index 861c7d05b..d28212fab 100644 --- a/src/server/api/campaign.js +++ b/src/server/api/campaign.js @@ -500,10 +500,6 @@ export const resolvers = { repliesStaleAfter: (campaign) => campaign.replies_stale_after_minutes, isAssignmentLimitedToTeams: (campaign) => campaign.limit_assignment_to_teams, - dueBy: (campaign) => - campaign.due_by instanceof Date || !campaign.due_by - ? campaign.due_by || null - : new Date(campaign.due_by), organization: async (campaign, _, { loaders }) => campaign.organization || loaders.organization.load(campaign.organization_id), diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js index 08ac5ecf0..f4b623742 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -4,7 +4,7 @@ import { config } from "../../config"; import { UNASSIGNED_TEXTER } from "../../lib/constants"; import { eventBus, EventType } from "../event-bus"; import { r } from "../models"; -import { addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue } from "./assignment"; +import { addWhereClauseForContactsFilterMessageStatus } from "./assignment"; import { buildCampaignQuery } from "./campaign"; const getConversationsJoinsAndWhereClause = async ( @@ -92,7 +92,7 @@ const getConversationsJoinsAndWhereClause = async ( } } - query = addWhereClauseForContactsFilterMessageStatusIrrespectiveOfPastDue( + query = addWhereClauseForContactsFilterMessageStatus( query, contactsFilter && contactsFilter.messageStatus ); @@ -237,7 +237,6 @@ export const getConversations = async ( "user.last_name as u_last_name", "campaign.id as cmp_id", "campaign.title", - "campaign.due_by", "assignment.id as ass_id", "message.id as mess_id", "message.text", diff --git a/src/server/api/lib/campaign.spec.ts b/src/server/api/lib/campaign.spec.ts index 9f2bf3523..3c05aa78b 100644 --- a/src/server/api/lib/campaign.spec.ts +++ b/src/server/api/lib/campaign.spec.ts @@ -160,7 +160,6 @@ describe("create / edit campaign", () => { campaign: { title: "New Campaign", description: "", - dueBy: null, organizationId: `${organization.id}`, contacts: [], interactionSteps: { diff --git a/src/server/api/lib/campaign.ts b/src/server/api/lib/campaign.ts index 9c5614f29..6de6d980b 100644 --- a/src/server/api/lib/campaign.ts +++ b/src/server/api/lib/campaign.ts @@ -197,7 +197,6 @@ export const copyCampaign = async (options: CopyCampaignOptions) => { is_approved, is_started, is_archived, - due_by, logo_image_url, intro_html, primary_color, @@ -220,7 +219,6 @@ export const copyCampaign = async (options: CopyCampaignOptions) => { false as is_approved, false as is_started, false as is_archived, - due_by, logo_image_url, intro_html, primary_color, @@ -424,7 +422,6 @@ export const editCampaign = async ( const { title, description, - dueBy, logoImageUrl, introHtml, primaryColor, @@ -443,7 +440,6 @@ export const editCampaign = async ( id, title: title ?? undefined, description: description ?? undefined, - due_by: dueBy, organization_id: organizationId, logo_image_url: logoImageUrl, primary_color: primaryColor, diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index b05ea4539..996e24b73 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -797,7 +797,6 @@ const rootMutations = { creator_id: user.id, title: campaign.title, description: campaign.description, - due_by: campaign.dueBy, timezone: parseIanaZone(organization.default_texting_tz), is_started: false, is_archived: false, diff --git a/src/server/api/types.ts b/src/server/api/types.ts index 04ef18bc2..e2f5e329d 100644 --- a/src/server/api/types.ts +++ b/src/server/api/types.ts @@ -105,7 +105,6 @@ export interface CampaignRecord { is_approved: boolean; is_started: boolean | null; is_template: boolean; - due_by: string | null; created_at: string; is_archived: boolean | null; logo_image_url: string | null;