diff --git a/services/web/app/src/Features/Analytics/AnalyticsManager.js b/services/web/app/src/Features/Analytics/AnalyticsManager.js index 4afdb08f83..e37e70c22a 100644 --- a/services/web/app/src/Features/Analytics/AnalyticsManager.js +++ b/services/web/app/src/Features/Analytics/AnalyticsManager.js @@ -7,6 +7,7 @@ const crypto = require('crypto') const _ = require('lodash') const { expressify } = require('@overleaf/promise-utils') const logger = require('@overleaf/logger') +const { createHash } = require('crypto') const analyticsEventsQueue = Queues.getQueue('analytics-events') const analyticsEditingSessionsQueue = Queues.getQueue( @@ -18,6 +19,7 @@ const analyticsUserPropertiesQueue = Queues.getQueue( const analyticsAccountMappingQueue = Queues.getQueue( 'analytics-account-mapping' ) +const analyticsEmailChangeQueue = Queues.getQueue('analytics-email-change') const ONE_MINUTE_MS = 60 * 1000 @@ -148,6 +150,7 @@ function setUserPropertyForSessionInBackground(session, property, value) { /** * @typedef {(import('./types').AccountMapping)} AccountMapping + * @typedef {(import('./types').EmailChangePayload)} EmailChangePayload */ /** @@ -193,6 +196,59 @@ function registerAccountMapping({ }) } +/** + * Registers operations relating to email addresses. + * + * Generally, this should be called through the EmailChangeHelper module to ensure + * that the right data is sent. + * + * This will be called whenever an email is created, updated or deleted. + * + * @param {EmailChangePayload} payload + */ +function registerEmailChange({ + userId, + email, + action, + createdAt = new Date(), + emailCreatedAt, + isPrimary, + emailConfirmedAt, + emailDeletedAt, +}) { + Metrics.analyticsQueue.inc({ status: 'adding', event_type: 'email-change' }) + + const salt = Settings.analytics?.hashedEmailSalt ?? '' + + const hashedEmail = createHash('sha256') + .update(`${email}${salt}`) + .digest('hex') + + analyticsEmailChangeQueue + .add('email-change', { + action, + createdAt, + emailDeletedAt, + email: hashedEmail, + emailConfirmedAt, + emailCreatedAt, + isPrimary, + userId, + }) + .then(() => { + Metrics.analyticsQueue.inc({ + status: 'added', + event_type: 'email-change', + }) + }) + .catch(() => { + Metrics.analyticsQueue.inc({ + status: 'error', + event_type: 'email-change', + }) + }) +} + function updateEditingSession(userId, projectId, countryCode, segmentation) { if (!userId) { return @@ -400,5 +456,6 @@ module.exports = { updateEditingSession, getIdsFromSession, registerAccountMapping, + registerEmailChange, analyticsIdMiddleware: expressify(analyticsIdMiddleware), } diff --git a/services/web/app/src/Features/Analytics/types.d.ts b/services/web/app/src/Features/Analytics/types.d.ts index 44df848bd0..5f368f73eb 100644 --- a/services/web/app/src/Features/Analytics/types.d.ts +++ b/services/web/app/src/Features/Analytics/types.d.ts @@ -7,3 +7,14 @@ export type AccountMapping = { targetEntityId: string createdAt: string } + +export type EmailChangePayload = { + userId: string + email: string + isPrimary: boolean + action: 'created' | 'deleted' | 'updated' + createdAt: string + emailDeletedAt?: string | null + emailCreatedAt?: string | null + emailConfirmedAt?: string | null +} diff --git a/services/web/test/unit/src/Analytics/AnalyticsManagerTests.js b/services/web/test/unit/src/Analytics/AnalyticsManagerTests.js index fcd70fcddb..38f8bd7497 100644 --- a/services/web/test/unit/src/Analytics/AnalyticsManagerTests.js +++ b/services/web/test/unit/src/Analytics/AnalyticsManagerTests.js @@ -16,7 +16,7 @@ describe('AnalyticsManager', function () { this.fakeUserId = 'dbfc9438d14996f73dd172fb' this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55' this.Settings = { - analytics: { enabled: true }, + analytics: { enabled: true, hashedEmailSalt: 'salt' }, } this.analyticsEventsQueue = { add: sinon.stub().resolves(), @@ -307,6 +307,30 @@ describe('AnalyticsManager', function () { message ) }) + + it('email change', async function () { + const message = { + userId: this.fakeUserId, + email: 'test@example.com', + createdAt: '2021-01-01T00:00:00Z', + action: 'created', + emailCreatedAt: '2021-01-01T00:00:00Z', + isPrimary: false, + } + this.AnalyticsManager.registerEmailChange(message) + const convertedMessage = { + ...message, + emailConfirmedAt: undefined, + emailDeletedAt: undefined, + email: + '1778d425d64c5259ef7b574a2488647eb51ca739a0b16bfa0e2e3e16fff362db', // sha256 hash of email + salt + } + sinon.assert.calledWithMatch( + this.analyticsEmailChangeQueue.add, + 'email-change', + convertedMessage + ) + }) }) describe('AnalyticsIdMiddleware', function () {