diff --git a/packages/server/__tests__/db/db.test.js b/packages/server/__tests__/db/db.test.js index fedb7b15b..54c38e324 100644 --- a/packages/server/__tests__/db/db.test.js +++ b/packages/server/__tests__/db/db.test.js @@ -44,7 +44,7 @@ describe('db', () => { 'DRY RUN :: Migrating agency criteria for agency 4', 'DRY RUN :: Migrating agency criteria for users 1,2 belonging to agency 0', 'DRY RUN :: No agency criteria to migrate for users 3 belonging to agency 1', - 'DRY RUN :: No agency criteria to migrate for users 4 belonging to agency 2', + 'DRY RUN :: No agency criteria to migrate for users 4,5 belonging to agency 2', 'DRY RUN :: No users to migrate for agency 4', 'DRY RUN :: Would have inserted approximately 2 saved searches. Note: there may be duplicates.', 'DRY RUN :: Done migrating legacy agency criteria to saved searches', @@ -70,7 +70,7 @@ describe('db', () => { 'Migrating agency criteria for agency 4', 'Migrating agency criteria for users 1,2 belonging to agency 0', 'No agency criteria to migrate for users 3 belonging to agency 1', - 'No agency criteria to migrate for users 4 belonging to agency 2', + 'No agency criteria to migrate for users 4,5 belonging to agency 2', 'No users to migrate for agency 4', 'Inserted 2 saved searches', 'Done migrating legacy agency criteria to saved searches', @@ -117,7 +117,7 @@ describe('db', () => { 'Migrating agency criteria for agency 4', 'Migrating agency criteria for users 1,2 belonging to agency 0', 'No agency criteria to migrate for users 3 belonging to agency 1', - 'No agency criteria to migrate for users 4 belonging to agency 2', + 'No agency criteria to migrate for users 4,5 belonging to agency 2', 'No users to migrate for agency 4', 'Inserted 1 saved searches', // This would have been 2 if not for the duplication mechanism. 'Done migrating legacy agency criteria to saved searches', diff --git a/packages/server/__tests__/db/seeds/fixtures.js b/packages/server/__tests__/db/seeds/fixtures.js index ed2a65fee..d1fbe9e14 100644 --- a/packages/server/__tests__/db/seeds/fixtures.js +++ b/packages/server/__tests__/db/seeds/fixtures.js @@ -89,6 +89,14 @@ const users = { id: 4, tenant_id: agencies.usdr.tenant_id, }, + usdrAdmin: { + email: 'usdr.admin@test.com', + name: 'USDR admin', + agency_id: agencies.usdr.id, + role_id: roles.adminRole.id, + id: 5, + tenant_id: agencies.usdr.tenant_id, + }, }; const keywords = { diff --git a/packages/server/__tests__/email/email.test.js b/packages/server/__tests__/email/email.test.js index ff1d96110..540c3324f 100644 --- a/packages/server/__tests__/email/email.test.js +++ b/packages/server/__tests__/email/email.test.js @@ -9,6 +9,9 @@ const emailService = require('../../src/lib/email/service-email'); const email = require('../../src/lib/email'); const fixtures = require('../db/seeds/fixtures'); const db = require('../../src/db'); +const { tags, notificationType, emailSubscriptionStatus } = require('../../src/lib/email/constants'); + +const { knex } = db; const awsTransport = require('../../src/lib/gost-aws'); const { @@ -216,19 +219,20 @@ describe('Email module', () => { describe('Email sender', () => { const sandbox = sinon.createSandbox(); before(async () => { - await fixtures.seed(db.knex); process.env.DD_SERVICE = 'test-dd-service'; process.env.DD_ENV = 'test-dd-env'; process.env.DD_VERSION = 'test-dd-version'; }); + after(async () => { - await db.knex.destroy(); + await knex.destroy(); process.env.DD_SERVICE = DD_SERVICE; process.env.DD_ENV = DD_ENV; process.env.DD_VERSION = DD_VERSION; }); - beforeEach(() => { + beforeEach(async () => { + await fixtures.seed(knex); sandbox.spy(emailService); }); @@ -571,4 +575,92 @@ describe('Email sender', () => { expect(sendFake.calledOnce).to.equal(true); }); }); + + context('buildAndSendGrantActivityDigestEmails', () => { + const { adminUser, staffUser } = fixtures.users; + const grant1 = fixtures.grants.earFellowship; + + let periodStart; + let periodEnd; + + let sendFake; + + beforeEach(async () => { + sendFake = sinon.fake.returns('foo'); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); + + await knex('email_subscriptions').insert([ + { + user_id: adminUser.id, + agency_id: adminUser.agency_id, + notification_type: notificationType.grantActivity, + status: emailSubscriptionStatus.subscribed, + }, + { + user_id: staffUser.id, + agency_id: staffUser.agency_id, + notification_type: notificationType.grantActivity, + status: emailSubscriptionStatus.subscribed, + }, + ]); + + periodStart = new Date(); + + // Grant 1 Follows + await knex('grant_followers') + .insert([ + { grant_id: grant1.grant_id, user_id: adminUser.id }, + { grant_id: grant1.grant_id, user_id: staffUser.id }, + ], 'id'); + + // Grant 1 Notes + const [adminNote, staffNote] = await knex('grant_notes') + .insert([ + { grant_id: grant1.grant_id, user_id: adminUser.id }, + { grant_id: grant1.grant_id, user_id: staffUser.id }, + ], 'id'); + + await knex('grant_notes_revisions') + .insert([ + { grant_note_id: adminNote.id, text: 'Admin note' }, + { grant_note_id: staffNote.id, text: 'Staff note' }, + ], 'id'); + + periodEnd = new Date(); + }); + + it('Sends an email for all users with activity', async () => { + // Send digest email for users following grants + await email.buildAndSendGrantActivityDigestEmails(null, periodStart, periodEnd); + expect(sendFake.callCount).to.equal(2); + + const [firstEmail, secondEmail] = sendFake.getCalls(); + + expect([firstEmail.args[0].toAddress, secondEmail.args[0].toAddress]).to.be.members([adminUser.email, staffUser.email]); + }); + + it('Sends an email for single user\'s digest', async () => { + // Build digest for user following grants + await email.buildAndSendGrantActivityDigestEmails(adminUser.id, periodStart, periodEnd); + expect(sendFake.calledOnce).to.equal(true); + + const { body, text, ...rest } = sendFake.firstCall.args[0]; + + expect(rest).to.deep.equal({ + fromName: 'USDR Federal Grant Finder', + ccAddress: undefined, + toAddress: adminUser.email, + subject: 'New activity in your grants', + tags: [ + `notification_type=${tags.emailTypes.grantActivityDigest}`, + 'user_role=admin', + `organization_id=${adminUser.tenant_id}`, + `team_id=${adminUser.agency_id}`, + 'service=test-dd-service', + 'env=test-dd-env', + 'version=test-dd-version', + ], + }); + }); + }); }); diff --git a/packages/server/__tests__/lib/grants-collaboration.test.js b/packages/server/__tests__/lib/grants-collaboration.test.js index 53435e81c..40908b0dd 100644 --- a/packages/server/__tests__/lib/grants-collaboration.test.js +++ b/packages/server/__tests__/lib/grants-collaboration.test.js @@ -1,15 +1,25 @@ const { expect, use } = require('chai'); const chaiAsPromised = require('chai-as-promised'); const { DateTime } = require('luxon'); +const _ = require('lodash'); const knex = require('../../src/db/connection'); const fixtures = require('../db/seeds/fixtures'); +const { seed } = require('../db/seeds/fixtures'); const { saveNoteRevision, getOrganizationNotesForGrant, getOrganizationNotesForGrantByUser } = require('../../src/lib/grantsCollaboration/notes'); const { followGrant, unfollowGrant, getFollowerForGrant, getFollowersForGrant, } = require('../../src/lib/grantsCollaboration/followers'); +const { + getGrantActivityByUserId, getGrantActivityEmailRecipients, +} = require('../../src/lib/grantsCollaboration/grantActivity'); +const emailConstants = require('../../src/lib/email/constants'); use(chaiAsPromised); +beforeEach(async () => { + await seed(knex); +}); + describe('Grants Collaboration', () => { context('saveNoteRevision', () => { it('creates new note', async () => { @@ -243,4 +253,213 @@ describe('Grants Collaboration', () => { expect(result.pagination.next).to.equal(follower2.id); }); }); + + context('Grant Activity', () => { + // Helper functions + const getUserIdsForActivities = (grants) => { + const ids = grants.reduce((userIds, grant) => { + grant.notes.forEach((act) => userIds.add(act.userId)); + grant.follows.forEach((act) => userIds.add(act.userId)); + return userIds; + }, new Set()); + + return Array.from(ids); + }; + + const subscribeUser = async (user) => { + await knex('email_subscriptions').insert({ + user_id: user.id, + agency_id: user.agency_id, + notification_type: emailConstants.notificationType.grantActivity, + status: emailConstants.emailSubscriptionStatus.subscribed, + }); + }; + + const { adminUser, staffUser } = fixtures.users; + const grant1 = fixtures.grants.earFellowship; + const grant2 = fixtures.grants.healthAide; + + let periodStart; + let periodEnd; + + let grant1NoteAdmin; + let grant1NoteStaff; + + beforeEach(async () => { + await subscribeUser(adminUser); + await subscribeUser(staffUser); + + periodStart = DateTime.now().minus({ days: 1 }).toJSDate(); + + // Grant 1 Follows + await knex('grant_followers') + .insert({ grant_id: grant1.grant_id, user_id: adminUser.id }, 'id'); + + await knex('grant_followers') + .insert({ grant_id: grant1.grant_id, user_id: staffUser.id }, 'id'); + + // Grant 1 Notes + [grant1NoteAdmin] = await knex('grant_notes') + .insert({ grant_id: grant1.grant_id, user_id: adminUser.id }, 'id'); + + await knex('grant_notes_revisions') + .insert({ grant_note_id: grant1NoteAdmin.id, text: 'Admin note' }, 'id'); + + [grant1NoteStaff] = await knex('grant_notes') + .insert({ grant_id: grant1.grant_id, user_id: staffUser.id }, 'id'); + + await knex('grant_notes_revisions') + .insert({ grant_note_id: grant1NoteStaff.id, text: 'Staff note' }, 'id'); + + // Grant 2 Follows + await knex('grant_followers') + .insert({ grant_id: grant2.grant_id, user_id: staffUser.id }, 'id'); + + await knex('grant_followers') + .insert({ grant_id: grant2.grant_id, user_id: adminUser.id }, 'id'); + + // Grant 2 Notes + const [grant2NoteStaff] = await knex('grant_notes') + .insert({ grant_id: grant2.grant_id, user_id: staffUser.id }, 'id'); + + await knex('grant_notes_revisions') + .insert({ grant_note_id: grant2NoteStaff.id, text: 'Another Staff note' }, 'id'); + + periodEnd = new Date(); + }); + + it('retrieves all email recipients for note/follow activity by period', async () => { + const recipients = await getGrantActivityEmailRecipients(knex, periodStart, periodEnd); + + expect(recipients).to.have.length(2); + + const userIds = _.map(recipients, 'userId'); + + expect(userIds).to.have.members([ + adminUser.id, + staffUser.id, + ]); + }); + + it('does not return recipients if users are unsubscribed', async () => { + // Unsubscribe all users + await knex('email_subscriptions').update({ status: emailConstants.emailSubscriptionStatus.unsubscribed }); + + const recipients = await getGrantActivityEmailRecipients(knex, periodStart, periodEnd); + + expect(recipients).to.have.length(0); + }); + + it('retrieves all note/follow activity by period', async () => { + const grantActivities = await getGrantActivityByUserId(knex, adminUser.id, periodStart, periodEnd); + + expect(grantActivities).to.have.lengthOf(2); + expect(grantActivities[0].grantId).to.equal(grant1.grant_id); + expect(grantActivities[0].notes).to.have.lengthOf(1); + expect(grantActivities[0].follows).to.have.lengthOf(1); + expect(grantActivities[0].notes[0].noteText).to.equal('Staff note'); + expect(grantActivities[0].follows[0].userId).to.equal(staffUser.id); + }); + + it('retrieves email recipients only if OTHER users took action', async () => { + periodStart = new Date(); + + // Admin edits note + const [adminRevised] = await knex('grant_notes_revisions') + .insert({ grant_note_id: grant1NoteAdmin.id, text: 'Edit for Admin note' }, 'created_at'); + + const recipients1 = await getGrantActivityEmailRecipients(knex, periodStart, new Date(adminRevised.created_at.getTime() + 1)); + expect(_.map(recipients1, 'userId')).to.have.members([staffUser.id]); + + // Staff edits note + const [staffRevised] = await knex('grant_notes_revisions') + .insert({ grant_note_id: grant1NoteStaff.id, text: 'Edit for Staff note' }, 'created_at'); + + const recipients2 = await getGrantActivityEmailRecipients(knex, periodStart, new Date(staffRevised.created_at.getTime() + 1)); + expect(_.map(recipients2, 'userId')).to.have.members([staffUser.id, adminUser.id]); + }); + + it('retrieves activity only for OTHER users action', async () => { + const adminGrantActivities = await getGrantActivityByUserId(knex, adminUser.id, periodStart, periodEnd); + const adminActivityIds = getUserIdsForActivities(adminGrantActivities); + expect(adminActivityIds).not.to.include(adminUser.id); + + const staffGrantActivities = await getGrantActivityByUserId(knex, staffUser.id, periodStart, periodEnd); + const staffActivityIds = getUserIdsForActivities(staffGrantActivities); + expect(staffActivityIds).not.to.include(staffUser.id); + }); + + it('retrieves no note/follow activity when window is outside time period of activity', async () => { + periodStart = DateTime.fromJSDate(periodStart).minus({ days: 1 }).toJSDate(); + periodEnd = DateTime.fromJSDate(periodEnd).minus({ days: 1 }).toJSDate(); + + const recipients = await getGrantActivityEmailRecipients(knex, periodStart, periodEnd); + expect(recipients).to.have.lengthOf(0); + + await Promise.all( + [adminUser.id, staffUser.id].map(async (userId) => { + const grantActivities = await getGrantActivityByUserId(knex, userId, periodStart, periodEnd); + expect(grantActivities).to.have.lengthOf(0); + }), + ); + }); + + context('Grant activity by other org/tenants', async () => { + const { usdrUser: otherUser1, usdrAdmin: otherUser2 } = fixtures.users; + + beforeEach(async () => { + await subscribeUser(otherUser1); + await subscribeUser(otherUser2); + + await knex('grant_followers') + .insert([ + { grant_id: grant1.grant_id, user_id: otherUser1.id }, + { grant_id: grant1.grant_id, user_id: otherUser2.id }, + ], 'id'); + + const [otherUser1Note, otherUser2Note] = await knex('grant_notes') + .insert([ + { grant_id: grant1.grant_id, user_id: otherUser1.id }, + { grant_id: grant1.grant_id, user_id: otherUser2.id }, + ], 'id'); + + await knex('grant_notes_revisions') + .insert([ + { grant_note_id: otherUser1Note.id, text: 'Other tenant note1' }, + { grant_note_id: otherUser2Note.id, text: 'Other tenant note2' }, + ], 'id'); + periodEnd = new Date(); + }); + + it('Includes recipients from multiple tenants', async () => { + // Email recipients INCLUDES multiple tenants + const recipients = await getGrantActivityEmailRecipients(knex, periodStart, periodEnd); + expect(_.map(recipients, 'userId')).to.have.members([ + adminUser.id, + staffUser.id, + otherUser1.id, + otherUser2.id, + ]); + }); + + it('Does NOT include cross-over activity events from multiple tenants', async () => { + const getGrantActivity = async (userId) => getGrantActivityByUserId(knex, userId, periodStart, periodEnd); + + const tenantOneUsers = [adminUser.id, staffUser.id]; + const tenantTwoUsers = [otherUser1.id, otherUser2.id]; + + const tenantOneGrants = await Promise.all( + tenantOneUsers.map((userId) => getGrantActivity(userId)), + ); + const tenantOneUserIds = getUserIdsForActivities(tenantOneGrants.flat()); + expect(tenantOneUserIds).not.to.include.members(tenantTwoUsers); + + const tenantTwoGrants = await Promise.all( + tenantTwoUsers.map((userId) => getGrantActivity(userId)), + ); + const tenantTwoUserIds = getUserIdsForActivities(tenantTwoGrants.flat()); + expect(tenantTwoUserIds).not.to.include.members(tenantOneUsers); + }); + }); + }); }); diff --git a/packages/server/__tests__/scripts/sendGrantActivityDigestEmail.test.js b/packages/server/__tests__/scripts/sendGrantActivityDigestEmail.test.js new file mode 100644 index 000000000..279008ef9 --- /dev/null +++ b/packages/server/__tests__/scripts/sendGrantActivityDigestEmail.test.js @@ -0,0 +1,30 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const email = require('../../src/lib/email'); +const sendGrantActivityDigestEmail = require('../../src/scripts/sendGrantActivityDigestEmail').main; + +describe('sendGrantActivityDigestEmail script', () => { + const sandbox = sinon.createSandbox(); + let buildAndSendGrantActivityDigestEmailsFake; + + beforeEach(() => { + buildAndSendGrantActivityDigestEmailsFake = sandbox.fake(); + sandbox.replace(email, 'buildAndSendGrantActivityDigestEmails', buildAndSendGrantActivityDigestEmailsFake); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('triggers sending digest emails when flags are on', async () => { + process.env.ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK = 'true'; + await sendGrantActivityDigestEmail(); + expect(buildAndSendGrantActivityDigestEmailsFake.called).to.equal(true); + }); + + it('triggers no emails when scheduled task flag is off', async () => { + process.env.ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK = 'false'; + await sendGrantActivityDigestEmail(); + expect(buildAndSendGrantActivityDigestEmailsFake.called).to.equal(false); + }); +}); diff --git a/packages/server/src/db/constants.js b/packages/server/src/db/constants.js index f8014100b..1549cc8c7 100644 --- a/packages/server/src/db/constants.js +++ b/packages/server/src/db/constants.js @@ -14,5 +14,8 @@ module.exports = { keywords: 'keywords', email_subscriptions: 'email_subscriptions', grants_saved_searches: 'grants_saved_searches', + grant_notes: 'grant_notes', + grant_followers: 'grant_followers', + grant_notes_revisions: 'grant_notes_revisions', }, }; diff --git a/packages/server/src/lib/email.js b/packages/server/src/lib/email.js index 88331428c..ec79c61e0 100644 --- a/packages/server/src/lib/email.js +++ b/packages/server/src/lib/email.js @@ -2,6 +2,7 @@ const tracer = require('dd-trace').init(); const { URL } = require('url'); const moment = require('moment'); const { capitalize } = require('lodash'); +const { DateTime } = require('luxon'); // eslint-disable-next-line import/no-unresolved const asyncBatch = require('async-batch').default; const fileSystem = require('fs'); @@ -10,6 +11,9 @@ const mustache = require('mustache'); const { log } = require('./logging'); const emailService = require('./email/service-email'); const db = require('../db'); + +const { knex } = db; +const { getGrantActivityByUserId, getGrantActivityEmailRecipients } = require('./grantsCollaboration/grantActivity'); const { notificationType, tags } = require('./email/constants'); const { isUSDR, isUSDRSuperAdmin } = require('./access-helpers'); @@ -465,6 +469,43 @@ async function buildDigestBody({ name, openDate, matchedGrants }) { return formattedBody; } +async function buildGrantActivityDigestBody({ name, grants, periodEnd }) { + // Build Email Here + // + // + const formatActivities = (activities) => activities.reduce((output, activity) => `${output} +
  • + ${activity.userName} - ${activity.agencyName}
    + ${activity.userEmail}

    + ${activity.noteText || ''} +
  • + `, ''); + + let body = ''; + grants.forEach((grant) => { + body += `

    ${grant.grantTitle}

    `; + + body += `

    ${grant.notes.length} New Notes:

    `; + body += ` + + `; + + body += `

    ${grant.follows.length} New Follows:

    `; + body += ` + + `; + body += '
    '; + }); + + const formattedBody = mustache.render(body, { + body_title: `${name}: New activity in your grants`, + body_detail: DateTime.fromJSDate(periodEnd).toFormat('DDD'), + additional_body: '', + }); + + return formattedBody; +} + async function sendGrantDigestEmail({ name, matchedGrants, matchedGrantsTotal, recipient, openDate, }) { @@ -502,6 +543,40 @@ async function sendGrantDigestEmail({ ); } +async function sendGrantActivityDigestEmail({ + name, recipientEmail, grants, periodEnd, +}) { + if (!grants || grants?.length === 0) { + console.error(`There was no grant note/follow activity available for ${name}`); + return; + } + console.log(`${name} is will receive digests on ${DateTime.fromJSDate(periodEnd).toLocaleString(DateTime.DATE_FULL)}`); + + const formattedBody = await buildGrantActivityDigestBody({ name, grants, periodEnd }); + const preheader = 'See recent activity from grants you follow'; + + const emailHTML = addBaseBranding(formattedBody, { + tool_name: 'Federal Grant Finder', + title: 'New activity in your grants', + preheader, + includeNotificationsLink: true, + }); + + // TODO: add plain text version of the email + const emailPlain = emailHTML.replace(/<[^>]+>/g, ''); + + await deliverEmail( + { + fromName: GRANT_FINDER_EMAIL_FROM_NAME, + toAddress: recipientEmail, + emailHTML, + emailPlain, + subject: 'New activity in your grants', + emailType: tags.emailTypes.grantActivityDigest, + }, + ); +} + async function getAndSendGrantForSavedSearch({ userSavedSearch, openDate, }) { @@ -528,6 +603,23 @@ async function getAndSendGrantForSavedSearch({ }); } +async function getAndSendGrantActivityDigest({ + userId, + userName, + userEmail, + periodStart, + periodEnd, +}) { + const grantActivityResults = await getGrantActivityByUserId(knex, userId, periodStart, periodEnd); + + return sendGrantActivityDigestEmail({ + name: userName, + recipientEmail: userEmail, + grants: grantActivityResults, + periodEnd, + }); +} + function yesterday() { return moment().subtract(1, 'day').format('YYYY-MM-DD'); } @@ -553,6 +645,35 @@ async function buildAndSendGrantDigestEmails(userId, openDate = yesterday()) { console.log(`Successfully built and sent grants digest emails for ${inputs.length} saved searches on ${openDate}`); } +async function buildAndSendGrantActivityDigestEmails(explicitUserId, periodStart, periodEnd) { + const userGroup = explicitUserId ? `user Id ${explicitUserId}` : 'all users'; + console.log(` + Building and sending Grant Activity Digest email for ${userGroup} on ${DateTime.fromJSDate(periodEnd).toLocaleString(DateTime.DATE_FULL)} + `); + /* + 1. get all email recipients + 2. call getAndSendGrantActivityDigest to find activity for each user and send the digest + */ + let recipients = await getGrantActivityEmailRecipients(knex, periodStart, periodEnd); + + if (explicitUserId) { + // Send specific user only if activity exists + recipients = recipients.filter((recipient) => recipient.userId === explicitUserId); + } + + const inputs = recipients.map(({ userId, userName, userEmail }) => ({ + userId, + userName, + userEmail, + periodStart, + periodEnd, + })); + + await asyncBatch(inputs, getAndSendGrantActivityDigest, 2); + + console.log(`Successfully built and sent grants digest emails for ${inputs.length} users on ${periodEnd}`); +} + async function sendAsyncReportEmail(recipient, signedUrl, reportType) { const formattedBodyTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_formatted_body.html')); @@ -592,6 +713,7 @@ module.exports = { * Send grant digest emails to all subscribed users. */ buildAndSendGrantDigestEmails, + buildAndSendGrantActivityDigestEmails, sendGrantDigestEmail, // Exposed for testing buildDigestBody, diff --git a/packages/server/src/lib/email/constants.js b/packages/server/src/lib/email/constants.js index 8466b708e..dab205aaf 100644 --- a/packages/server/src/lib/email/constants.js +++ b/packages/server/src/lib/email/constants.js @@ -29,12 +29,15 @@ const tags = Object.freeze( treasuryReport: 'treasury_report', welcome: 'welcome', grantDigest: 'grant_digest', + grantActivityDigest: 'grant_activity_digest', treasuryReportError: 'treasury_report_error', auditReportError: 'audit_report_error', }, }, ); +const TZ_NY = 'America/New_York'; + module.exports = { - notificationType, emailSubscriptionStatus, defaultSubscriptionPreference, tags, + notificationType, emailSubscriptionStatus, defaultSubscriptionPreference, tags, TZ_NY, }; diff --git a/packages/server/src/lib/grantsCollaboration/grantActivity.js b/packages/server/src/lib/grantsCollaboration/grantActivity.js new file mode 100644 index 000000000..63aa38fa8 --- /dev/null +++ b/packages/server/src/lib/grantsCollaboration/grantActivity.js @@ -0,0 +1,149 @@ +const _ = require('lodash'); +const { TABLES } = require('../../db/constants'); +const emailConstants = require('../email/constants'); + +const getActivitiesQuery = (knex) => { + const noteRevisionsSub = knex + .select() + .from({ r: TABLES.grant_notes_revisions }) + .whereRaw('r.grant_note_id = gn.id') + .orderBy('r.created_at', 'desc') + .limit(1); + + return knex.unionAll([ + knex + .select([ + 'gf.id', + 'gf.grant_id', + 'gf.user_id', + 'gf.created_at AS activity_at', + knex.raw(`'follow' AS activity_type`), + knex.raw('null AS text_content'), + ]) + .from({ gf: 'grant_followers' }), + knex + .select([ + 'rev.id', + 'gn.grant_id', + 'gn.user_id', + 'rev.created_at AS activity_at', + knex.raw(`'note' AS activity_type`), + 'rev.text AS text_content', + ]) + .from({ gn: 'grant_notes' }) + .joinRaw(`LEFT JOIN LATERAL (${noteRevisionsSub.toString()}) AS rev ON rev.grant_note_id = gn.id`), + ]) + .as('activity'); +}; + +async function getGrantActivityEmailRecipients(knex, periodStart, periodEnd) { + const query = knex + .select([ + 'recipient_users.id AS recipient_user_id', + 'recipient_users.name AS recipient_user_name', + 'recipient_users.email AS recipient_user_email', + ]) + .from( + getActivitiesQuery(knex), + ) + .join({ recipient_followers: TABLES.grant_followers }, 'recipient_followers.grant_id', 'activity.grant_id') + .join({ activity_users: TABLES.users }, 'activity_users.id', 'activity.user_id') + .join({ recipient_users: TABLES.users }, 'recipient_users.id', 'recipient_followers.user_id') + .leftJoin({ recipient_subscriptions: TABLES.email_subscriptions }, (builder) => { + builder + .on(`recipient_followers.user_id`, '=', `recipient_subscriptions.user_id`) + .on(`recipient_subscriptions.notification_type`, '=', knex.raw('?', [emailConstants.notificationType.grantActivity])); + }) + .where('activity.activity_at', '>=', periodStart) + .andWhere('activity.activity_at', '<=', periodEnd) + .andWhere('recipient_subscriptions.status', emailConstants.emailSubscriptionStatus.subscribed) + // only consider actions taken by users in the same organization as the recipient: + .andWhereRaw('recipient_users.tenant_id = activity_users.tenant_id') + // exclude rows where the recipient user is the one taking the action, + // to ensure that users only receive a digest if OTHER users took action: + .andWhereRaw('recipient_followers.user_id != activity.user_id') + .groupBy([ + 'recipient_user_id', + 'recipient_user_name', + 'recipient_user_email', + ]); + + const results = await query; + + return results.map((recipient) => ({ + userId: recipient.recipient_user_id, + userEmail: recipient.recipient_user_email, + userName: recipient.recipient_user_name, + })); +} + +async function getGrantActivityByUserId(knex, userId, periodStart, periodEnd) { + const query = knex.select([ + 'g.grant_id AS grant_id', + 'g.title AS grant_title', + 'recipient_users.name AS recipient_user_name', + 'recipient_users.email AS recipient_user_email', + 'activity_users.id AS user_id', + 'activity_users.name AS user_name', + 'activity_users.email AS user_email', + 'activity_users_agencies.name AS agency_name', + 'activity.activity_at', + 'activity.activity_type', + 'activity.text_content AS note_text', + ]) + .from( + getActivitiesQuery(knex), + ) + .join({ recipient_followers: TABLES.grant_followers }, 'recipient_followers.grant_id', 'activity.grant_id') + // incorporate users table for users responsible for the activity: + .join({ activity_users: TABLES.users }, 'activity_users.id', 'activity.user_id') + // incorporate users table for the recipient follower: + .join({ recipient_users: TABLES.users }, 'recipient_users.id', 'recipient_followers.user_id') + // Additional JOINs for data selected for use in the email's content: + .join({ g: TABLES.grants }, 'g.grant_id', 'activity.grant_id') + .join({ activity_users_agencies: TABLES.agencies }, 'activity_users_agencies.id', 'activity_users.agency_id') + .where('activity.activity_at', '>', periodStart) + .andWhere('activity.activity_at', '<', periodEnd) + // Limit to activity where the user performing the activity belongs to the same organization: + .andWhereRaw('activity_users.tenant_id = recipient_users.tenant_id') + // limit activity to grants for which the recipient user is a follower + .andWhere('recipient_followers.user_id', userId) + .andWhereNot('activity_users.id', userId) + .orderBy([ + { column: 'g.grant_id', order: 'desc' }, + { column: 'activity.activity_at', order: 'asc' }, + ]); + + const results = await query; + + // Group by grant + const resultsByGrant = _.groupBy(results, 'grant_id'); + + // Grant IDs distinct + const grantIds = [...new Set(_.map(results, 'grant_id'))]; + + return grantIds.map((grantId) => { + const activities = resultsByGrant[grantId].map((act) => ({ + userId: act.user_id, + userName: act.user_name, + userEmail: act.user_email, + agencyName: act.agency_name, + activityAt: act.activity_at, + activityType: act.activity_type, + noteText: act.note_text, + })); + const activitiesByType = _.groupBy(activities, 'activityType'); + + return { + grantId, + grantTitle: resultsByGrant[grantId][0].grant_title, + notes: activitiesByType.note || [], + follows: activitiesByType.follow || [], + }; + }); +} + +module.exports = { + getGrantActivityByUserId, + getGrantActivityEmailRecipients, +}; diff --git a/packages/server/src/scripts/sendGrantActivityDigestEmail.js b/packages/server/src/scripts/sendGrantActivityDigestEmail.js new file mode 100644 index 000000000..9197fd812 --- /dev/null +++ b/packages/server/src/scripts/sendGrantActivityDigestEmail.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +const tracer = require('dd-trace').init(); +const { DateTime } = require('luxon'); +const { log } = require('../lib/logging'); +const email = require('../lib/email'); +const { TZ_NY } = require('../lib/email/constants'); + +/** + * This script sends all enabled grant activity digest emails. It is triggered by a + * scheduled ECS task configured in terraform to run on a daily basis. + * + * This setup is intended to be a temporary fix until we've set up more + * robust email infrastructure. See here for context: + * https://github.com/usdigitalresponse/usdr-gost/issues/2133 + */ +exports.main = async function main() { + if (process.env.ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK !== 'true') { + return; + } + + await tracer.trace('sendGrantActivityDigestEmail', async () => { + log.info('Sending grant activity digest emails'); + const periodEnd = DateTime.local({ hours: 8, zone: TZ_NY }); + const periodStart = periodEnd.minus({ days: 1 }); + await email.buildAndSendGrantActivityDigestEmails(null, periodStart.toJSDate(), periodEnd.toJSDate()); + }); +}; + +if (require.main === module) { + exports.main().then(() => process.exit()); +} diff --git a/terraform/main.tf b/terraform/main.tf index 06526ae91..92aaf4f12 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -177,14 +177,15 @@ module "api" { ecs_cluster_name = join("", aws_ecs_cluster.default[*].name) # Task configuration - docker_tag = var.api_container_image_tag - default_desired_task_count = var.api_default_desired_task_count - autoscaling_desired_count_minimum = var.api_minumum_task_count - autoscaling_desired_count_maximum = var.api_maximum_task_count - enable_new_team_terminology = var.api_enable_new_team_terminology - enable_saved_search_grants_digest = var.api_enable_saved_search_grants_digest - enable_grant_digest_scheduled_task = var.api_enable_grant_digest_scheduled_task - unified_service_tags = local.unified_service_tags + docker_tag = var.api_container_image_tag + default_desired_task_count = var.api_default_desired_task_count + autoscaling_desired_count_minimum = var.api_minumum_task_count + autoscaling_desired_count_maximum = var.api_maximum_task_count + enable_new_team_terminology = var.api_enable_new_team_terminology + enable_saved_search_grants_digest = var.api_enable_saved_search_grants_digest + enable_grant_digest_scheduled_task = var.api_enable_grant_digest_scheduled_task + enable_grant_activity_digest_scheduled_task = var.api_enable_grant_activity_digest_scheduled_task + unified_service_tags = local.unified_service_tags datadog_environment_variables = merge( var.default_datadog_environment_variables, var.api_datadog_environment_variables, diff --git a/terraform/modules/gost_api/grant_activity_digest.tf b/terraform/modules/gost_api/grant_activity_digest.tf new file mode 100644 index 000000000..d4d61572b --- /dev/null +++ b/terraform/modules/gost_api/grant_activity_digest.tf @@ -0,0 +1,46 @@ +module "grant_activity_digest_scheduled_task" { + source = "../scheduled_ecs_task" + enabled = var.enabled && var.enable_grant_activity_digest_scheduled_task + + name_prefix = "${var.namespace}-grant-activ-digest-task" + description = "Executes an ECS task that sends grants activity digest emails at 8am ET" + + # Schedule + schedule_expression = "cron(0 8 * * *)" + schedule_expression_timezone = "America/New_York" + flexible_time_window = { hours = 1 } + # Until we have more robust email retry handling, we'll limit to 1 attempt to avoid sending multiple duplicate emails to a portion of the audience + retry_policy_max_attempts = 1 + retry_policy_max_event_age = { hours = 4 } + + // Permissions + task_role_arn = join("", aws_ecs_task_definition.default[*].task_role_arn) + task_execution_role_arn = join("", aws_ecs_task_definition.default[*].execution_role_arn) + permissions_boundary_arn = var.permissions_boundary_arn + + // Task settings + cluster_arn = join("", data.aws_ecs_cluster.default[*].arn) + task_definition_arn = join("", aws_ecs_task_definition.default[*].arn) + task_revision = "LATEST" + launch_type = "FARGATE" + enable_ecs_managed_tags = true + enable_execute_command = false + + task_override = jsonencode({ + containerOverrides = [ + { + name = "api" + command = [ + "node", + "./src/scripts/sendGrantActivityDigestEmail.js" + ] + }, + ] + }) + + network_configuration = { + assign_public_ip = false + security_groups = var.security_group_ids + subnets = var.subnet_ids + } +} diff --git a/terraform/modules/gost_api/task.tf b/terraform/modules/gost_api/task.tf index ef4c9cd9d..a07066e19 100644 --- a/terraform/modules/gost_api/task.tf +++ b/terraform/modules/gost_api/task.tf @@ -44,18 +44,19 @@ module "api_container_definition" { map_environment = merge( { - API_DOMAIN = "https://${var.domain_name}" - AUDIT_REPORT_BUCKET = module.arpa_audit_reports_bucket.bucket_id - DATA_DIR = "/var/data" - ENABLE_SAVED_SEARCH_GRANTS_DIGEST = var.enable_saved_search_grants_digest ? "true" : "false" - ENABLE_GRANT_DIGEST_SCHEDULED_TASK = var.enable_grant_digest_scheduled_task ? "true" : "false" - ENABLE_NEW_TEAM_TERMINOLOGY = var.enable_new_team_terminology ? "true" : "false" - NODE_OPTIONS = "--max_old_space_size=1024" - NOTIFICATIONS_EMAIL = var.notifications_email_address - PGSSLROOTCERT = "rds-global-bundle.pem" - SES_CONFIGURATION_SET_DEFAULT = var.ses_configuration_set_default - VUE_APP_GRANTS_API_URL = module.api_gateway.apigatewayv2_api_api_endpoint - WEBSITE_DOMAIN = "https://${var.website_domain_name}" + API_DOMAIN = "https://${var.domain_name}" + AUDIT_REPORT_BUCKET = module.arpa_audit_reports_bucket.bucket_id + DATA_DIR = "/var/data" + ENABLE_SAVED_SEARCH_GRANTS_DIGEST = var.enable_saved_search_grants_digest ? "true" : "false" + ENABLE_GRANT_DIGEST_SCHEDULED_TASK = var.enable_grant_digest_scheduled_task ? "true" : "false" + ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK = var.enable_grant_activity_digest_scheduled_task ? "true" : "false" + ENABLE_NEW_TEAM_TERMINOLOGY = var.enable_new_team_terminology ? "true" : "false" + NODE_OPTIONS = "--max_old_space_size=1024" + NOTIFICATIONS_EMAIL = var.notifications_email_address + PGSSLROOTCERT = "rds-global-bundle.pem" + SES_CONFIGURATION_SET_DEFAULT = var.ses_configuration_set_default + VUE_APP_GRANTS_API_URL = module.api_gateway.apigatewayv2_api_api_endpoint + WEBSITE_DOMAIN = "https://${var.website_domain_name}" }, local.datadog_env_vars, var.api_container_environment, diff --git a/terraform/modules/gost_api/variables.tf b/terraform/modules/gost_api/variables.tf index a84066a23..1e32d284c 100644 --- a/terraform/modules/gost_api/variables.tf +++ b/terraform/modules/gost_api/variables.tf @@ -179,6 +179,12 @@ variable "enable_grant_digest_scheduled_task" { default = false } +variable "enable_grant_activity_digest_scheduled_task" { + description = "When true, sets the ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK environment variable to true in the API container." + type = bool + default = false +} + variable "unified_service_tags" { description = "Datadog unified service tags to apply to runtime environments." type = object({ diff --git a/terraform/prod.tfvars b/terraform/prod.tfvars index c6255dc75..f309902e6 100644 --- a/terraform/prod.tfvars +++ b/terraform/prod.tfvars @@ -66,14 +66,15 @@ website_google_tag_id = "G-WCDTMFM6RG" cluster_container_insights_enabled = true // API / Backend -api_enabled = true -api_default_desired_task_count = 3 -api_minumum_task_count = 2 -api_maximum_task_count = 5 -api_enable_new_team_terminology = true -api_enable_saved_search_grants_digest = true -api_enable_grant_digest_scheduled_task = true -api_log_retention_in_days = 30 +api_enabled = true +api_default_desired_task_count = 3 +api_minumum_task_count = 2 +api_maximum_task_count = 5 +api_enable_new_team_terminology = true +api_enable_saved_search_grants_digest = true +api_enable_grant_digest_scheduled_task = true +api_enable_grant_activity_digest_scheduled_task = true +api_log_retention_in_days = 30 api_container_environment = { NEW_GRANT_DETAILS_PAGE_ENABLED = true SHARE_TERMINOLOGY_ENABLED = true diff --git a/terraform/sandbox.tfvars b/terraform/sandbox.tfvars index 904ffeb11..f757418ae 100644 --- a/terraform/sandbox.tfvars +++ b/terraform/sandbox.tfvars @@ -22,15 +22,16 @@ website_feature_flags = { cluster_container_insights_enabled = false // API / Backend -api_enabled = true -api_container_image_tag = "latest" -api_default_desired_task_count = 1 -api_minumum_task_count = 1 -api_maximum_task_count = 5 -api_enable_new_team_terminology = false -api_enable_saved_search_grants_digest = false -api_enable_grant_digest_scheduled_task = false -api_log_retention_in_days = 7 +api_enabled = true +api_container_image_tag = "latest" +api_default_desired_task_count = 1 +api_minumum_task_count = 1 +api_maximum_task_count = 5 +api_enable_new_team_terminology = false +api_enable_saved_search_grants_digest = false +api_enable_grant_digest_scheduled_task = false +api_enable_grant_activity_digest_scheduled_task = false +api_log_retention_in_days = 7 // Postgres postgres_enabled = true diff --git a/terraform/staging.tfvars b/terraform/staging.tfvars index 69284a8de..a2f3dd3b0 100644 --- a/terraform/staging.tfvars +++ b/terraform/staging.tfvars @@ -65,14 +65,15 @@ website_google_tag_id = "G-D5DFR7BN0N" cluster_container_insights_enabled = true // API / Backend -api_enabled = true -api_default_desired_task_count = 1 -api_minumum_task_count = 1 -api_maximum_task_count = 5 -api_enable_new_team_terminology = true -api_enable_saved_search_grants_digest = true -api_enable_grant_digest_scheduled_task = true -api_log_retention_in_days = 14 +api_enabled = true +api_default_desired_task_count = 1 +api_minumum_task_count = 1 +api_maximum_task_count = 5 +api_enable_new_team_terminology = true +api_enable_saved_search_grants_digest = true +api_enable_grant_digest_scheduled_task = true +api_enable_grant_activity_digest_scheduled_task = true +api_log_retention_in_days = 14 api_container_environment = { NEW_GRANT_DETAILS_PAGE_ENABLED = true, SHARE_TERMINOLOGY_ENABLED = true, diff --git a/terraform/variables.tf b/terraform/variables.tf index a80f975a0..e4feb914f 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -218,6 +218,10 @@ variable "api_enable_grant_digest_scheduled_task" { type = bool } +variable "api_enable_grant_activity_digest_scheduled_task" { + type = bool +} + variable "api_log_retention_in_days" { type = number }