diff --git a/services/web/app/src/Features/Analytics/EmailChangeHelper.js b/services/web/app/src/Features/Analytics/EmailChangeHelper.js new file mode 100644 index 0000000000..b60274adfc --- /dev/null +++ b/services/web/app/src/Features/Analytics/EmailChangeHelper.js @@ -0,0 +1,122 @@ +// @ts-check +const UserGetter = require('../User/UserGetter') +const { registerEmailChange } = require('./AnalyticsManager') + +/** + * @typedef {object} EmailData + * @property {string} email + * @property {Date} createdAt + * @property {Date} confirmedAt + * @property {boolean} default + */ + +/** + * @typedef {object} EventData + * @property {Date} [emailCreatedAt] ISO string of when the email was created + * @property {Date} [emailConfirmedAt] ISO string of when the email was confirmed + * @property {Date} [emailDeletedAt] ISO string of when the email was deleted + * @property {boolean} [isPrimary] Whether the email is the primary email + */ + +/** + * @typedef {import('./types').EmailChangePayload} EmailChangePayload + */ + +/** + * + * @param {string} userId + * @param {string} email + * @param {EventData} eventData + * @returns {Promise} + */ +async function registerEmailUpdate(userId, email, eventData = {}) { + const emailChangeEvent = await makeEmailChangeEvent(userId, email, eventData) + + registerEmailChange({ + ...emailChangeEvent, + action: 'updated', + }) +} + +/** + * + * @param {string} userId + * @param {string} email + * @param {EventData} eventData + * @returns {Promise>} + */ +async function makeEmailChangeEvent(userId, email, eventData) { + const userEmails = await UserGetter.promises.getUserFullEmails(userId) + const emailData = userEmails.find(userEmail => userEmail.email === email) + + const filledEventData = fillMissingEventData(eventData, emailData) + + return { + userId, + email, + createdAt: new Date().toISOString(), + emailCreatedAt: filledEventData?.emailCreatedAt?.toISOString() ?? null, + emailConfirmedAt: filledEventData?.emailConfirmedAt?.toISOString() ?? null, + emailDeletedAt: filledEventData?.emailDeletedAt?.toISOString() ?? null, + isPrimary: filledEventData?.isPrimary ?? false, + } +} + +/** + * + * @param {string} userId + * @param {string} email + * @param {EventData} eventData + * @returns {Promise} + */ +async function registerEmailCreation(userId, email, eventData = {}) { + const emailChangeEvent = await makeEmailChangeEvent(userId, email, eventData) + + registerEmailChange({ + ...emailChangeEvent, + action: 'created', + }) +} + +/** + * + * @param {string} userId + * @param {string} email + * @param {EventData} eventData + * @returns {Promise} + */ +async function registerEmailDeletion(userId, email, eventData = {}) { + const emailChangeEvent = await makeEmailChangeEvent(userId, email, eventData) + + registerEmailChange({ + ...emailChangeEvent, + action: 'deleted', + }) +} + +/** + * + * @param {EventData} eventData + * @param {EmailData | null} emailData + * @return {EventData} + */ +function fillMissingEventData(eventData, emailData) { + if (emailData) { + if (!eventData.emailCreatedAt) { + eventData.emailCreatedAt = emailData.createdAt + } + if (!eventData.emailConfirmedAt && emailData.confirmedAt) { + eventData.emailConfirmedAt = emailData.confirmedAt + } + if (eventData.isPrimary === undefined) { + eventData.isPrimary = emailData.default + } + } + return eventData +} + +module.exports = { + registerEmailUpdate, + registerEmailCreation, + registerEmailDeletion, +} diff --git a/services/web/test/unit/src/Analytics/EmailChangeHelpersTests.js b/services/web/test/unit/src/Analytics/EmailChangeHelpersTests.js new file mode 100644 index 0000000000..f874458667 --- /dev/null +++ b/services/web/test/unit/src/Analytics/EmailChangeHelpersTests.js @@ -0,0 +1,268 @@ +const SandboxedModule = require('sandboxed-module') +const sinon = require('sinon') +const { expect } = require('chai') + +describe('EmailChangeHelper', function () { + let AnalyticsManager + let UserGetter + let EmailChangeHelpers + const email = 'test@example.com' + const userId = '507f1f77bcf86cd799439011' + beforeEach(function () { + UserGetter = { + promises: { + getUserFullEmails: sinon.stub().resolves([]), + }, + } + AnalyticsManager = { + registerEmailChange: sinon.stub(), + } + EmailChangeHelpers = SandboxedModule.require( + '../../../../app/src/Features/Analytics/EmailChangeHelper', + { + requires: { + '../User/UserGetter': UserGetter, + './AnalyticsManager': AnalyticsManager, + }, + } + ) + }) + + describe('registerEmailUpdate', function () { + describe('when the email cannot be matched', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test2@example.com', + reversedHostname: 'moc.elpmaxe', + createdAt: new Date('2023-01-01T00:00:00'), + confirmedAt: new Date('2023-02-01T00:00:00'), + default: false, + }, + ]) + }) + + it('calls registerEmailChange with the passed event data', async function () { + const eventData = { + emailCreatedAt: new Date('2024-01-01T00:00:00'), + isPrimary: true, + } + await EmailChangeHelpers.registerEmailUpdate(userId, email, eventData) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'updated', + isPrimary: true, + }) + expect(callArgs.emailCreatedAt).to.eql('2024-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.be.null + }) + }) + describe('when the email can be matched', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.resolves([ + { + email, + reversedHostname: 'moc.elpmaxe', + createdAt: new Date('2023-01-01T00:00:00'), + confirmedAt: new Date('2023-02-01T00:00:00'), + default: false, + }, + ]) + }) + + it('calls registerEmailChange with the email data', async function () { + await EmailChangeHelpers.registerEmailUpdate(userId, email) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'updated', + isPrimary: false, + }) + expect(callArgs.emailCreatedAt).to.eql('2023-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.eql('2023-02-01T00:00:00.000Z') + expect(callArgs.emailDeletedAt).to.be.null + }) + + it('prefers supplied event data over fetched email data', async function () { + const eventData = { + emailCreatedAt: new Date('2024-01-01T00:00:00'), + emailConfirmedAt: new Date('2024-02-01T00:00:00'), + isPrimary: true, + } + await EmailChangeHelpers.registerEmailUpdate(userId, email, eventData) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'updated', + isPrimary: true, + }) + expect(callArgs.emailCreatedAt).to.eql('2024-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.eql('2024-02-01T00:00:00.000Z') + expect(callArgs.emailDeletedAt).to.be.null + }) + }) + + describe('when the user is not found', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.rejects( + new Error('User not found') + ) + }) + it('throws the error', async function () { + await expect( + EmailChangeHelpers.registerEmailUpdate(userId, email) + ).to.eventually.be.rejectedWith('User not found') + }) + }) + }) + + describe('registerEmailCreation', function () { + describe('when the email cannot be matched', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test2@example.com', + reversedHostname: 'moc.elpmaxe', + createdAt: new Date('2023-01-01T00:00:00'), + confirmedAt: new Date('2023-02-01T00:00:00'), + default: false, + }, + ]) + }) + + it('calls registerEmailChange with the passed event data', async function () { + const eventData = { + emailCreatedAt: new Date('2024-01-01T00:00:00'), + isPrimary: true, + } + await EmailChangeHelpers.registerEmailCreation(userId, email, eventData) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'created', + isPrimary: true, + }) + expect(callArgs.emailCreatedAt).to.eql('2024-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.be.null + expect(callArgs.emailDeletedAt).to.be.null + }) + }) + describe('when the email can be matched', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.resolves([ + { + email, + reversedHostname: 'moc.elpmaxe', + createdAt: new Date('2023-01-01T00:00:00'), + confirmedAt: new Date('2023-02-01T00:00:00'), + default: false, + }, + ]) + }) + + it('calls registerEmailChange with the email data', async function () { + await EmailChangeHelpers.registerEmailCreation(userId, email) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'created', + isPrimary: false, + }) + expect(callArgs.emailCreatedAt).to.eql('2023-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.eql('2023-02-01T00:00:00.000Z') + expect(callArgs.emailDeletedAt).to.be.null + }) + + it('prefers supplied event data over fetched email data', async function () { + const eventData = { + emailCreatedAt: new Date('2024-01-01T00:00:00'), + emailConfirmedAt: new Date('2024-02-01T00:00:00'), + isPrimary: true, + } + await EmailChangeHelpers.registerEmailCreation(userId, email, eventData) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'created', + isPrimary: true, + }) + expect(callArgs.emailCreatedAt).to.eql('2024-01-01T00:00:00.000Z') + expect(callArgs.emailConfirmedAt).to.eql('2024-02-01T00:00:00.000Z') + expect(callArgs.emailDeletedAt).to.be.null + }) + }) + describe('when the user is not found', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.rejects( + new Error('User not found') + ) + }) + it('throws the error', async function () { + await expect( + EmailChangeHelpers.registerEmailCreation(userId, email) + ).to.eventually.be.rejectedWith('User not found') + }) + }) + }) + + describe('registerEmailDeletion', function () { + describe('when the email cannot be matched', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.resolves([ + { + email: 'test2@example.com', + reversedHostname: 'moc.elpmaxe', + createdAt: new Date('2023-01-01T00:00:00'), + confirmedAt: new Date('2023-02-01T00:00:00'), + default: false, + }, + ]) + }) + + it('calls registerEmailChange with the passed event data', async function () { + const eventData = { + emailCreatedAt: new Date('2024-01-01T00:00:00'), + emailDeletedAt: new Date('2025-02-01T00:00:00'), + isPrimary: true, + } + await EmailChangeHelpers.registerEmailDeletion(userId, email, eventData) + expect(AnalyticsManager.registerEmailChange).to.have.been.calledOnce + const callArgs = AnalyticsManager.registerEmailChange.getCall(0).args[0] + expect(callArgs).to.include({ + userId, + email, + action: 'deleted', + isPrimary: true, + }) + expect(callArgs.emailCreatedAt).to.eql('2024-01-01T00:00:00.000Z') + expect(callArgs.emailDeletedAt).to.eql('2025-02-01T00:00:00.000Z') + }) + }) + + describe('when the user is not found', function () { + beforeEach(function () { + UserGetter.promises.getUserFullEmails.rejects( + new Error('User not found') + ) + }) + it('throws the error', async function () { + await expect( + EmailChangeHelpers.registerEmailDeletion(userId, email) + ).to.eventually.be.rejectedWith('User not found') + }) + }) + }) +})