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 += `
+ ${formatActivities(grant.notes)}
+ `;
+
+ body += `${grant.follows.length} New Follows:
`;
+ body += `
+ ${formatActivities(grant.follows)}
+ `;
+ 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
}