Skip to content

Commit

Permalink
include email subscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
greg-adams committed Oct 15, 2024
1 parent 3534522 commit 7630232
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 66 deletions.
19 changes: 17 additions & 2 deletions packages/server/__tests__/email/email.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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 } = require('../../src/lib/email/constants');
const { tags, notificationType, emailSubscriptionStatus } = require('../../src/lib/email/constants');

const { knex } = db;
const awsTransport = require('../../src/lib/gost-aws');
Expand Down Expand Up @@ -223,7 +223,6 @@ describe('Email sender', () => {
process.env.DD_SERVICE = 'test-dd-service';
process.env.DD_ENV = 'test-dd-env';
process.env.DD_VERSION = 'test-dd-version';
sandbox.spy(emailService);
});

after(async () => {
Expand All @@ -235,6 +234,7 @@ describe('Email sender', () => {

beforeEach(async () => {
await fixtures.seed(knex);
sandbox.spy(emailService);
});

afterEach(() => {
Expand Down Expand Up @@ -590,6 +590,21 @@ describe('Email sender', () => {
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
Expand Down
128 changes: 81 additions & 47 deletions packages/server/__tests__/lib/grants-collaboration.test.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
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);

afterEach(async () => {
await seed(knex);
});

describe('Grants Collaboration', () => {
context('saveNoteRevision', () => {
it('creates new note', async () => {
Expand Down Expand Up @@ -260,6 +265,15 @@ describe('Grants Collaboration', () => {
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;
Expand All @@ -271,6 +285,9 @@ describe('Grants Collaboration', () => {
let grant1NoteStaff;

beforeEach(async () => {
subscribeUser(adminUser);
subscribeUser(staffUser);

periodStart = DateTime.now().minus({ days: 1 }).toJSDate();

// Grant 1 Follows
Expand Down Expand Up @@ -321,6 +338,15 @@ describe('Grants Collaboration', () => {
]);
});

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 grantActivity = await getGrantActivityByUserId(knex, adminUser.id, periodStart, periodEnd);

Expand Down Expand Up @@ -377,56 +403,64 @@ describe('Grants Collaboration', () => {
);
});

it('Grant activity results should ignore activity by other org/tenants', async () => {
context('Grant activity by other org/tenants', async () => {
const { usdrUser: otherUser1, usdrAdmin: otherUser2 } = fixtures.users;

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();

// Email recipients INCLUDES multiple tenants
expect(await getGrantActivityEmailRecipients(knex, periodStart, periodEnd)).to.have.members([
adminUser.id,
staffUser.id,
otherUser1.id,
otherUser2.id,
]);

const getGrantActivity = async (userId) => {
const grantActivity = await getGrantActivityByUserId(knex, userId, periodStart, periodEnd);
return grantActivity.grants;
};

const tenantOneUsers = [adminUser.id, staffUser.id];
const tenantTwoUsers = [otherUser1.id, otherUser2.id];
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();
});

// Digest activity does NOT include cross-over between tenants
const tenantOneGrants = await Promise.all(
tenantOneUsers.map((userId) => getGrantActivity(userId)),
);
const tenantOneUserIds = getUserIdsForActivities(tenantOneGrants.flat());
expect(tenantOneUserIds).not.to.include.members(tenantTwoUsers);
it('Includes recipients from multiple tenants', async () => {
// Email recipients INCLUDES multiple tenants
expect(await getGrantActivityEmailRecipients(knex, periodStart, periodEnd)).to.have.members([
adminUser.id,
staffUser.id,
otherUser1.id,
otherUser2.id,
]);
});

const tenantTwoGrants = await Promise.all(
tenantTwoUsers.map((userId) => getGrantActivity(userId)),
);
const tenantTwoUserIds = getUserIdsForActivities(tenantTwoGrants.flat());
expect(tenantTwoUserIds).not.to.include.members(tenantOneUsers);
it('Does NOT include cross-over activity events from multiple tenants', async () => {
const getGrantActivity = async (userId) => {
const grantActivity = await getGrantActivityByUserId(knex, userId, periodStart, periodEnd);
return grantActivity.grants;
};

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);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ describe('sendGrantActivityDigestEmail script', () => {
let buildAndSendGrantActivityDigestEmailsFake;

beforeEach(() => {
process.env.ENABLE_GRANT_ACTIVITY_DIGEST_SCHEDULED_TASK = 'true';
buildAndSendGrantActivityDigestEmailsFake = sandbox.fake();
sandbox.replace(email, 'buildAndSendGrantActivityDigestEmails', buildAndSendGrantActivityDigestEmailsFake);
});
Expand All @@ -18,6 +17,7 @@ describe('sendGrantActivityDigestEmail script', () => {
});

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);
});
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/db/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
32 changes: 28 additions & 4 deletions packages/server/src/lib/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,8 +473,31 @@ async function buildGrantActivityDigestBody({ name, grants, periodEnd }) {
// Build Email Here
//
//
const formatActivities = (activities) => activities.reduce((output, activity) => `${output}
<li>
${activity.userName} - ${activity.agencyName}<br/>
${activity.userEmail}<br/><br/>
${activity.noteText || ''}
</li>
`, '');

let body = '';
grants.forEach((grant) => {
body += `<h2>${grant.grantTitle}</h2>`;

body += `<h3>${grant.notes.length} New Notes:</h3>`;
body += `
<ul>${formatActivities(grant.notes)}</ul>
`;

body += `<h3>${grant.follows.length} New Follows:</h3>`;
body += `
<ul>${formatActivities(grant.follows)}</ul>
`;
body += '<hr>';
});

const formattedBody = mustache.render(JSON.stringify(grants), {
const formattedBody = mustache.render(body, {
body_title: `${name}: New activity in your grants`,
body_detail: DateTime.fromJSDate(periodEnd).toFormat('DDD'),
additional_body: '',
Expand Down Expand Up @@ -523,12 +546,11 @@ async function sendGrantDigestEmail({
async function sendGrantActivityDigestEmail({
name, recipientEmail, grants, periodEnd,
}) {
console.log(`${name} is subscribed for digests on ${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';
Expand Down Expand Up @@ -623,7 +645,9 @@ async function buildAndSendGrantDigestEmails(userId, openDate = yesterday()) {

async function buildAndSendGrantActivityDigestEmails(userId, periodStart, periodEnd) {
const userGroup = userId ? `user Id ${userId}` : 'all users';
console.log(`Building and sending Grant Activity Digest email for ${userGroup} on ${periodEnd}`);
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
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/lib/email/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const tags = Object.freeze(
},
);

const TZ_NY = 'America/New_York';

module.exports = {
notificationType, emailSubscriptionStatus, defaultSubscriptionPreference, tags,
notificationType, emailSubscriptionStatus, defaultSubscriptionPreference, tags, TZ_NY,
};
27 changes: 18 additions & 9 deletions packages/server/src/lib/grantsCollaboration/grantActivity.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const _ = require('lodash');
const { TABLES } = require('../../db/constants');
const emailConstants = require('../email/constants');

const getActivitiesQuery = (knex) => {
const noteRevisionsSub = knex
.select()
.from({ r: 'grant_notes_revisions' })
.from({ r: TABLES.grant_notes_revisions })
.whereRaw('r.grant_note_id = gn.id')
.orderBy('r.created_at', 'desc')
.limit(1);
Expand Down Expand Up @@ -40,11 +42,17 @@ async function getGrantActivityEmailRecipients(knex, periodStart, periodEnd) {
.from(
getActivitiesQuery(knex),
)
.join({ recipient_followers: 'grant_followers' }, 'recipient_followers.grant_id', 'activity.grant_id')
.join({ activity_users: 'users' }, 'activity_users.id', 'activity.user_id')
.join({ recipient_users: 'users' }, 'recipient_users.id', 'recipient_followers.user_id')
.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,
Expand Down Expand Up @@ -73,14 +81,14 @@ async function getGrantActivityByUserId(knex, userId, periodStart, periodEnd) {
.from(
getActivitiesQuery(knex),
)
.join({ recipient_followers: 'grant_followers' }, 'recipient_followers.grant_id', 'activity.grant_id')
.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: 'users' }, 'activity_users.id', 'activity.user_id')
.join({ activity_users: TABLES.users }, 'activity_users.id', 'activity.user_id')
// incorporate users table for the recipient follower:
.join({ recipient_users: 'users' }, 'recipient_users.id', 'recipient_followers.user_id')
.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: 'grants' }, 'g.grant_id', 'activity.grant_id')
.join({ activity_users_agencies: 'agencies' }, 'activity_users_agencies.id', 'activity_users.agency_id')
.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:
Expand Down Expand Up @@ -108,6 +116,7 @@ async function getGrantActivityByUserId(knex, userId, periodStart, periodEnd) {
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,
Expand Down
Loading

0 comments on commit 7630232

Please sign in to comment.