diff --git a/site/gatsby-site/cypress/e2e/integration/apps/submitted.cy.js b/site/gatsby-site/cypress/e2e/integration/apps/submitted.cy.js index ee8a00fd68..8f6489dadd 100644 --- a/site/gatsby-site/cypress/e2e/integration/apps/submitted.cy.js +++ b/site/gatsby-site/cypress/e2e/integration/apps/submitted.cy.js @@ -212,18 +212,6 @@ describe('Submitted reports', () => { expect(variables.subscription.userId.link).to.eq(user.userId); }); - cy.wait('@UpsertSubscriptionPromoted') - .its('request.body.variables') - .then((variables) => { - expect(variables.query.type).to.eq(SUBSCRIPTION_TYPE.submissionPromoted); - expect(variables.query.incident_id.incident_id).to.eq(182); - expect(variables.query.userId.userId).to.eq(submission.user.userId); - - expect(variables.subscription.type).to.eq(SUBSCRIPTION_TYPE.submissionPromoted); - expect(variables.subscription.incident_id.link).to.eq(182); - expect(variables.subscription.userId.link).to.eq(submission.user.userId); - }); - cy.contains( '[data-cy="toast"]', 'Successfully promoted submission to Incident 182 and Report 1565' diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications.cy.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications.cy.js deleted file mode 100644 index fea695c20b..0000000000 --- a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications.cy.js +++ /dev/null @@ -1,841 +0,0 @@ -const { SUBSCRIPTION_TYPE } = require('../../../../src/utils/subscriptions'); - -const processNotifications = require('../../../../../realm/functions/processNotifications'); - -const pendingNotificationsToNewIncidents = [ - { - _id: '63616f37d0db19c07d081300', - type: SUBSCRIPTION_TYPE.newIncidents, - incident_id: 217, - processed: false, - }, - { - _id: '63616f82d0db19c07d081301', - type: SUBSCRIPTION_TYPE.newIncidents, - incident_id: 218, - processed: false, - }, -]; - -const pendingNotificationsToNewEntityIncidents = [ - { - _id: '63616f82d0db19c07d081302', - type: SUBSCRIPTION_TYPE.entity, - incident_id: 219, - entity_id: 'google', - processed: false, - }, - { - _id: '63616f82d0db19c07d081303', - type: SUBSCRIPTION_TYPE.entity, - incident_id: 219, - entity_id: 'facebook', - isUpdate: true, - processed: false, - }, -]; - -const pendingNotificationsToIncidentUpdates = [ - { - _id: '63616f82d0db19c07d081304', - type: 'incident-updated', - incident_id: 219, - processed: false, - }, - { - _id: '63616f82d0db19c07d081305', - type: 'new-report-incident', - incident_id: 219, - report_number: 2000, - processed: false, - }, -]; - -const pendingNotificationsToPromotedIncidents = [ - { - _id: '63616f82d0db19c07d081306', - type: SUBSCRIPTION_TYPE.submissionPromoted, - incident_id: 217, - processed: false, - }, -]; - -const subscriptionsToNewIncidents = [ - { - _id: '6356e39e863169c997309586', - type: SUBSCRIPTION_TYPE.newIncidents, - userId: '63320ce63ec803072c9f5291', - }, - { - _id: '6356e39e863169c997309586', - type: SUBSCRIPTION_TYPE.newIncidents, - userId: '63321072f27421740a80af22', - }, -]; - -const subscriptionsToNewEntityIncidents = [ - { - _id: '6356e39e863169c997309586', - type: SUBSCRIPTION_TYPE.entity, - entityId: 'google', - userId: '63321072f27421740a80af23', - }, - { - _id: '6356e39e863169c997309586', - type: SUBSCRIPTION_TYPE.entity, - entityId: 'facebook', - userId: '63321072f27421740a80af24', - }, -]; - -const subscriptionsToIncidentUpdates = [ - { - userId: '63320ce63ec803072c9f5291', - type: SUBSCRIPTION_TYPE.incident, - incident_id: 219, - }, - { - userId: '63321072f27421740a80af22', - type: SUBSCRIPTION_TYPE.incident, - incident_id: 219, - }, -]; - -const subscriptionsToPromotedIncidents = [ - { - _id: '6356e39e863169c997309586', - type: SUBSCRIPTION_TYPE.submissionPromoted, - userId: '63320ce63ec803072c9f5291', - }, -]; - -const recipients = [ - { - email: 'test1@email.com', - userId: '63320ce63ec803072c9f5291', - }, - { - email: 'test2@email.com', - userId: '63321072f27421740a80af22', - }, - { - email: 'test3@email.com', - userId: '63321072f27421740a80af23', - }, - { - email: 'test4@email.com', - userId: '63321072f27421740a80af24', - }, -]; - -const incidents = [ - { - incident_id: 217, - 'Alleged developer of AI system': [], - 'Alleged deployer of AI system': [], - 'Alleged harmed or nearly harmed parties': [], - AllegedDeployerOfAISystem: [], - AllegedDeveloperOfAISystem: [], - AllegedHarmedOrNearlyHarmedParties: [], - __typename: 'Incident', - date: '2018-11-16', - description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', - nlp_similar_incidents: [], - reports: [1, 2], - title: '217 Amazon workers sent to hospital', - }, - { - incident_id: 218, - 'Alleged developer of AI system': [], - 'Alleged deployer of AI system': [], - 'Alleged harmed or nearly harmed parties': [], - __typename: 'Incident', - date: '2018-11-16', - description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', - nlp_similar_incidents: [], - reports: [1, 2], - title: '218 Amazon workers sent to hospital', - }, - { - incident_id: 219, - 'Alleged developer of AI system': ['google', 'facebook'], - 'Alleged deployer of AI system': ['facebook'], - 'Alleged harmed or nearly harmed parties': ['tesla'], - __typename: 'Incident', - date: '2018-11-16', - description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', - nlp_similar_incidents: [], - reports: [1, 2, 2000], - title: '218 Amazon workers sent to hospital', - }, -]; - -const reports = [ - { - report_number: 2000, - title: 'Report title', - authors: ['Pablo Costa', 'Aimee Picchi'], - }, -]; - -const entities = [ - { - entity_id: 'google', - name: 'Google', - }, - { - entity_id: 'facebook', - name: 'Facebook', - }, - { - entity_id: 'boston-university', - name: 'Boston University', - }, -]; - -const buildEntityList = (allEntities, entityIds) => { - const entityNames = entityIds.map((entityId) => { - const entity = allEntities.find((entity) => entity.entity_id === entityId); - - return entity - ? `${entity.name}` - : ''; - }); - - if (entityNames.length < 3) { - return entityNames.join(' and '); - } - - return `${entityNames.slice(0, -1).join(', ')}, and ${entityNames[entityNames.length - 1]}`; -}; - -const stubEverything = () => { - const notificationsCollection = { - find: (() => { - const stub = cy.stub(); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.newIncidents }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.newIncidents})`) - .returns({ toArray: () => pendingNotificationsToNewIncidents }); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.entity }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.entity})`) - .returns({ toArray: () => pendingNotificationsToNewEntityIncidents }); - - stub - .withArgs({ processed: false, type: { $in: ['new-report-incident', 'incident-updated'] } }) - .as(`notifications.find('new-report-incident', 'incident-updated')`) - .returns({ toArray: () => pendingNotificationsToIncidentUpdates }); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.submissionPromoted }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.submissionPromoted})`) - .returns({ toArray: () => pendingNotificationsToPromotedIncidents }); - - return stub; - })(), - updateOne: cy.stub().as('notifications.updateOne').resolves(), - }; - - const subscriptionsCollection = { - find: (() => { - const stub = cy.stub(); - - stub - .withArgs({ type: SUBSCRIPTION_TYPE.newIncidents }) - .as(`subscriptions.find("${SUBSCRIPTION_TYPE.newIncidents}")`) - .returns({ toArray: () => subscriptionsToNewIncidents }); - - for (const pendingNotification of pendingNotificationsToNewEntityIncidents) { - stub - .withArgs({ type: SUBSCRIPTION_TYPE.entity, entityId: pendingNotification.entity_id }) - .as( - `subscriptions.find("${SUBSCRIPTION_TYPE.entity}", "${pendingNotification.entity_id}")` - ) - .returns({ toArray: () => subscriptionsToNewEntityIncidents }); - } - - for (const pendingNotification of pendingNotificationsToIncidentUpdates) { - stub - .withArgs({ - type: SUBSCRIPTION_TYPE.incident, - incident_id: pendingNotification.incident_id, - }) - .as( - `subscriptions.find("${SUBSCRIPTION_TYPE.incident}", "${pendingNotification.incident_id}")` - ) - .returns({ toArray: () => subscriptionsToIncidentUpdates }); - } - - const incidentIds = pendingNotificationsToPromotedIncidents.map( - (pendingNotification) => pendingNotification.incident_id - ); - - stub - .withArgs({ - type: SUBSCRIPTION_TYPE.submissionPromoted, - incident_id: { $in: incidentIds }, - }) - .as(`subscriptions.find("${SUBSCRIPTION_TYPE.submissionPromoted}")`) - .returns({ toArray: () => subscriptionsToPromotedIncidents }); - - return stub; - })(), - }; - - const incidentsCollection = { - findOne: (() => { - const stub = cy.stub(); - - for (let index = 0; index < incidents.length; index++) { - const incident = incidents[index]; - - stub - .withArgs({ incident_id: incident.incident_id }) - .as(`incidents.findOne(${incident.incident_id})`) - .returns(incidents.find((i) => i.incident_id == incident.incident_id)); - } - - return stub; - })(), - }; - - const reportsCollection = { - findOne: (() => { - const stub = cy.stub(); - - for (let index = 0; index < reports.length; index++) { - const report = reports[index]; - - stub - .withArgs({ report_number: report.report_number }) - .as(`reports.findOne(${report.report_number})`) - .returns(reports.find((r) => r.report_number == report.report_number)); - } - - return stub; - })(), - }; - - const entitiesCollection = { - find: cy.stub().returns({ - toArray: cy.stub().as('entities.find').resolves(entities), - }), - }; - - global.context = { - // @ts-ignore - services: { - get: cy.stub().returns({ - db: cy.stub().returns({ - collection: (() => { - const stub = cy.stub(); - - stub.withArgs('notifications').returns(notificationsCollection); - stub.withArgs('subscriptions').returns(subscriptionsCollection); - stub.withArgs('incidents').returns(incidentsCollection); - stub.withArgs('entities').returns(entitiesCollection); - stub.withArgs('reports').returns(reportsCollection); - - return stub; - })(), - }), - }), - }, - functions: { - execute: (() => { - const stub = cy.stub(); - - for (const user of recipients) { - stub - .withArgs('getUser', { userId: user.userId }) - .as(`getUser(${user.userId})`) - .returns(recipients.find((r) => r.userId == user.userId)); - } - - stub.withArgs('sendEmail').as('sendEmail').returns({ statusCode: 200 }); - - return stub; - })(), - }, - }; - - global.BSON = { Int32: (x) => x }; - - return { - notificationsCollection, - subscriptionsCollection, - incidentsCollection, - entitiesCollection, - reportsCollection, - }; -}; - -describe('Functions', () => { - it('New Incidents - Should send pending notifications', () => { - const { notificationsCollection, subscriptionsCollection, incidentsCollection } = - stubEverything(); - - cy.wrap(processNotifications()).then((result) => { - expect(result, 'Notifications processed count').to.be.equal(7); - - expect(notificationsCollection.find.firstCall.args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.newIncidents, - }); - - expect(subscriptionsCollection.find.firstCall.args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.newIncidents, - }); - - for (const subscription of subscriptionsToNewIncidents) { - expect(global.context.functions.execute).to.be.calledWith('getUser', { - userId: subscription.userId, - }); - } - - for (let i = 0; i < pendingNotificationsToNewIncidents.length; i++) { - const pendingNotification = pendingNotificationsToNewIncidents[i]; - - expect(incidentsCollection.findOne.getCall(i).args[0]).to.deep.equal({ - incident_id: pendingNotification.incident_id, - }); - - const userIds = subscriptionsToNewIncidents.map((subscription) => subscription.userId); - - const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); - - const sendEmailParams = { - recipients: recipients.filter((r) => userIds.includes(r.userId)), - subject: 'New Incident {{incidentId}} was created', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - developers: buildEntityList(entities, incident['Alleged developer of AI system']), - deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), - entitiesHarmed: buildEntityList( - entities, - incident['Alleged harmed or nearly harmed parties'] - ), - }, - templateId: 'NewIncident', // Template value from function name sufix from "site/realm/functions/config.json" - }; - - expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); - - expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - - expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - }); - }); - - it('New Promotions - Should send pending submissions promoted notifications', () => { - const { notificationsCollection, subscriptionsCollection, incidentsCollection } = - stubEverything(); - - cy.wrap(processNotifications()).then((result) => { - expect(result, 'Notifications processed count').to.be.equal(7); - expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.submissionPromoted, - }); - - expect(subscriptionsCollection.find.getCall(5).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.submissionPromoted, - incident_id: { $in: [217] }, - }); - - for (const subscription of subscriptionsToPromotedIncidents) { - expect(global.context.functions.execute).to.be.calledWith('getUser', { - userId: subscription.userId, - }); - } - - for (let i = 0; i < pendingNotificationsToPromotedIncidents.length; i++) { - const pendingNotification = pendingNotificationsToPromotedIncidents[i]; - - expect(incidentsCollection.findOne.getCall(i).args[0]).to.deep.equal({ - incident_id: pendingNotification.incident_id, - }); - - const userIds = subscriptionsToPromotedIncidents.map((subscription) => subscription.userId); - - const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); - - const sendEmailParams = { - recipients: recipients.filter((r) => userIds.includes(r.userId)), - subject: 'Your submission has been approved!', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - }, - templateId: 'SubmissionApproved', // Template value from function name sufix from "site/realm/functions/config.json" - }; - - expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); - - expect(notificationsCollection.updateOne.getCall(6).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - - expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - }); - }); - - it('Entity - Should send pending notifications', () => { - const { - notificationsCollection, - subscriptionsCollection, - incidentsCollection, - entitiesCollection, - } = stubEverything(); - - cy.wrap(processNotifications()).then((result) => { - expect(result, 'Notifications processed count').to.be.equal(7); - - expect(notificationsCollection.find.secondCall.args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.entity, - }); - - expect(entitiesCollection.find.firstCall.args[0]).to.deep.equal({}); - - for (let i = 0; i < pendingNotificationsToNewEntityIncidents.length; i++) { - const pendingNotification = pendingNotificationsToNewEntityIncidents[i]; - - expect(subscriptionsCollection.find.getCall(i + 1).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.entity, - entityId: pendingNotification.entity_id, - }); - - for (const subscription of subscriptionsToNewEntityIncidents) { - expect(global.context.functions.execute).to.be.calledWith('getUser', { - userId: subscription.userId, - }); - } - - expect( - incidentsCollection.findOne.getCall(pendingNotificationsToNewIncidents.length + i).args[0] - ).to.deep.equal({ - incident_id: pendingNotification.incident_id, - }); - - const userIds = subscriptionsToNewEntityIncidents.map( - (subscription) => subscription.userId - ); - - const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); - - const entity = entities.find( - (entity) => entity.entity_id === pendingNotification.entity_id - ); - - const isIncidentUpdate = pendingNotification.isUpdate; - - const sendEmailParams = { - recipients: recipients.filter((r) => userIds.includes(r.userId)), - subject: isIncidentUpdate - ? 'Update Incident for {{entityName}}' - : 'New Incident for {{entityName}}', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - entityName: entity.name, - entityUrl: `https://incidentdatabase.ai/entities/${entity.entity_id}`, - developers: buildEntityList(entities, incident['Alleged developer of AI system']), - deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), - entitiesHarmed: buildEntityList( - entities, - incident['Alleged harmed or nearly harmed parties'] - ), - }, - // Template value from function name sufix from "site/realm/functions/config.json" - templateId: isIncidentUpdate ? 'EntityIncidentUpdated' : 'NewEntityIncident', - }; - - expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); - - expect( - notificationsCollection.updateOne.getCall(pendingNotificationsToNewIncidents.length + i) - .args[0] - ).to.deep.equal({ - _id: pendingNotification._id, - }); - - expect( - notificationsCollection.updateOne.getCall(pendingNotificationsToNewIncidents.length + i) - .args[1].$set.processed - ).to.be.equal(true); - expect( - notificationsCollection.updateOne.getCall(pendingNotificationsToNewIncidents.length + i) - .args[1].$set - ).to.have.ownProperty('sentDate'); - } - }); - }); - - it('Incident Updated - Should send pending notifications', () => { - const { - notificationsCollection, - subscriptionsCollection, - incidentsCollection, - entitiesCollection, - } = stubEverything(); - - cy.wrap(processNotifications()).then((result) => { - expect(result, 'Notifications processed count').to.be.equal(7); - - expect(notificationsCollection.find.secondCall.args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.entity, - }); - - expect(entitiesCollection.find.firstCall.args[0]).to.deep.equal({}); - - for (let i = 0; i < pendingNotificationsToIncidentUpdates.length; i++) { - const pendingNotification = pendingNotificationsToIncidentUpdates[i]; - - expect(subscriptionsCollection.find.getCall(i + 3).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.incident, - incident_id: pendingNotification.incident_id, - }); - - for (const subscription of subscriptionsToIncidentUpdates) { - expect(global.context.functions.execute).to.be.calledWith('getUser', { - userId: subscription.userId, - }); - } - - expect(incidentsCollection.findOne.getCall(i + 4).args[0]).to.deep.equal({ - incident_id: pendingNotification.incident_id, - }); - - const userIds = subscriptionsToIncidentUpdates.map((subscription) => subscription.userId); - - const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); - - const newReportNumber = pendingNotification.report_number; - - const newReport = newReportNumber - ? reports.find((r) => r.report_number == pendingNotification.report_number) - : null; - - const sendEmailParams = { - recipients: recipients.filter((r) => userIds.includes(r.userId)), - subject: 'Incident {{incidentId}} was updated', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, - reportUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}#r${newReportNumber}`, - reportTitle: newReportNumber ? newReport.title : '', - reportAuthor: newReportNumber && newReport.authors[0] ? newReport.authors[0] : '', - }, - templateId: newReportNumber // Template value from function name sufix from "site/realm/functions/config.json" - ? 'NewReportAddedToAnIncident' - : 'IncidentUpdate', - }; - - expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); - - expect(notificationsCollection.updateOne.getCall(i + 4).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - expect(notificationsCollection.updateOne.getCall(i + 4).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i + 4).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - }); - }); - - it('Should mark pending notifications as processed if there are no subscribers', () => { - const notificationsCollection = { - find: (() => { - const stub = cy.stub(); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.newIncidents }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.newIncidents})`) - .returns({ toArray: () => pendingNotificationsToNewIncidents }); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.entity }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.entity})`) - .returns({ toArray: () => pendingNotificationsToNewEntityIncidents }); - - stub - .withArgs({ - processed: false, - type: { $in: ['new-report-incident', 'incident-updated'] }, - }) - .as(`notifications.find('new-report-incident', 'incident-updated')`) - .returns({ toArray: () => pendingNotificationsToIncidentUpdates }); - - stub - .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.submissionPromoted }) - .as(`notifications.find(${SUBSCRIPTION_TYPE.submissionPromoted})`) - .returns({ toArray: () => pendingNotificationsToPromotedIncidents }); - - return stub; - })(), - updateOne: cy.stub().as('notifications.updateOne').resolves(), - }; - - const subscriptionsCollection = { - find: cy - .stub() - .as('subscriptions.find') - .returns({ - toArray: cy.stub().as('toArray').resolves([]), - }), - }; - - const entitiesCollection = { - find: cy - .stub() - .as('entities.find') - .returns({ - toArray: cy.stub().as('toArray').resolves(entities), - }), - }; - - global.context = { - // @ts-ignore - services: { - get: cy.stub().returns({ - db: cy.stub().returns({ - collection: (() => { - const stub = cy.stub(); - - stub.withArgs('notifications').returns(notificationsCollection); - stub.withArgs('subscriptions').returns(subscriptionsCollection); - stub.withArgs('entities').returns(entitiesCollection); - - return stub; - })(), - }), - }), - }, - functions: { - execute: cy.stub().as('functions.execute').resolves(), - }, - }; - - global.BSON = { Int32: (x) => x }; - - cy.wrap(processNotifications()).then((result) => { - expect(result, 'Notifications processed count').to.be.equal(7); - - expect(notificationsCollection.find.getCall(0).args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.newIncidents, - }); - - expect(notificationsCollection.find.getCall(1).args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.entity, - }); - - expect(notificationsCollection.find.getCall(2).args[0]).to.deep.equal({ - processed: false, - type: { $in: ['new-report-incident', 'incident-updated'] }, - }); - - expect(subscriptionsCollection.find.getCall(0).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.newIncidents, - }); - - expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ - processed: false, - type: SUBSCRIPTION_TYPE.submissionPromoted, - }); - - expect(global.context.functions.execute).not.to.be.called; - - for (let i = 0; i < pendingNotificationsToNewIncidents.length; i++) { - const pendingNotification = pendingNotificationsToNewIncidents[i]; - - expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - - for (let i = 0; i < pendingNotificationsToNewEntityIncidents.length; i++) { - const pendingNotification = pendingNotificationsToNewEntityIncidents[i]; - - expect(subscriptionsCollection.find.getCall(i + 1).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.entity, - entityId: pendingNotification.entity_id, - }); - - expect(notificationsCollection.updateOne.getCall(i + 2).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - expect(notificationsCollection.updateOne.getCall(i + 2).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i + 2).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - - for (let i = 0; i < pendingNotificationsToIncidentUpdates.length; i++) { - const pendingNotification = pendingNotificationsToIncidentUpdates[i]; - - expect(subscriptionsCollection.find.getCall(i + 3).args[0]).to.deep.equal({ - type: SUBSCRIPTION_TYPE.incident, - incident_id: pendingNotification.incident_id, - }); - - expect(notificationsCollection.updateOne.getCall(i + 4).args[0]).to.deep.equal({ - _id: pendingNotification._id, - }); - expect(notificationsCollection.updateOne.getCall(i + 4).args[1].$set.processed).to.be.equal( - true - ); - expect(notificationsCollection.updateOne.getCall(i + 4).args[1].$set).to.have.ownProperty( - 'sentDate' - ); - } - - expect( - notificationsCollection.updateOne.getCalls().length, - 'Notifications marked as processed count' - ).to.be.equal(7); - }); - }); -}); diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/fixtures.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/fixtures.js new file mode 100644 index 0000000000..f4f32d3686 --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/fixtures.js @@ -0,0 +1,83 @@ +export const entities = [ + { + entity_id: 'google', + name: 'Google', + }, + { + entity_id: 'facebook', + name: 'Facebook', + }, + { + entity_id: 'boston-university', + name: 'Boston University', + }, +]; + +export const incidents = [ + { + incident_id: 217, + 'Alleged developer of AI system': [], + 'Alleged deployer of AI system': [], + 'Alleged harmed or nearly harmed parties': [], + AllegedDeployerOfAISystem: [], + AllegedDeveloperOfAISystem: [], + AllegedHarmedOrNearlyHarmedParties: [], + __typename: 'Incident', + date: '2018-11-16', + description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', + nlp_similar_incidents: [], + reports: [1, 2], + title: '217 Amazon workers sent to hospital', + }, + { + incident_id: 218, + 'Alleged developer of AI system': [], + 'Alleged deployer of AI system': [], + 'Alleged harmed or nearly harmed parties': [], + __typename: 'Incident', + date: '2018-11-16', + description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', + nlp_similar_incidents: [], + reports: [1, 2], + title: '218 Amazon workers sent to hospital', + }, + { + incident_id: 219, + 'Alleged developer of AI system': ['google', 'facebook'], + 'Alleged deployer of AI system': ['facebook'], + 'Alleged harmed or nearly harmed parties': ['tesla'], + __typename: 'Incident', + date: '2018-11-16', + description: 'Twenty-four Amazon workers in New Jersey were hospitalized.', + nlp_similar_incidents: [], + reports: [1, 2, 2000], + title: '218 Amazon workers sent to hospital', + }, +]; + +export const reports = [ + { + report_number: 2000, + title: 'Report title', + authors: ['Pablo Costa', 'Aimee Picchi'], + }, +]; + +export const recipients = [ + { + email: 'test1@email.com', + userId: '63320ce63ec803072c9f5291', + }, + { + email: 'test2@email.com', + userId: '63321072f27421740a80af22', + }, + { + email: 'test3@email.com', + userId: '63321072f27421740a80af23', + }, + { + email: 'test4@email.com', + userId: '63321072f27421740a80af24', + }, +]; diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processEntityNotifications.cy.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processEntityNotifications.cy.js new file mode 100644 index 0000000000..acbda32fee --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processEntityNotifications.cy.js @@ -0,0 +1,285 @@ +import { buildEntityList, stubEverything } from './processNotificationsUtils'; + +const { SUBSCRIPTION_TYPE } = require('../../../../../src/utils/subscriptions'); + +const processNotifications = require('../../../../../../realm/functions/processNotifications'); + +const { recipients, entities, incidents } = require('./fixtures'); + +const pendingNotifications = [ + { + _id: '63616f82d0db19c07d081200', + type: SUBSCRIPTION_TYPE.entity, + incident_id: 219, + entity_id: 'google', + processed: false, + }, + { + _id: '63616f82d0db19c07d081201', + type: SUBSCRIPTION_TYPE.entity, + incident_id: 219, + entity_id: 'facebook', + isUpdate: true, + processed: false, + }, + //Duplicated pending notification + { + _id: '63616f82d0db19c07d081202', + type: SUBSCRIPTION_TYPE.entity, + incident_id: 219, + entity_id: 'facebook', + isUpdate: true, + processed: false, + }, + { + _id: '63616f82d0db19c07d081203', + type: SUBSCRIPTION_TYPE.entity, + incident_id: 219, + entity_id: 'google', + processed: false, + }, +]; + +const uniquePendingNotifications = pendingNotifications.slice(0, 2); + +const subscriptions = [ + { + _id: '6356e39e863169c997309586', + type: SUBSCRIPTION_TYPE.entity, + entityId: 'google', + userId: '63321072f27421740a80af23', + }, + { + _id: '6356e39e863169c997309587', + type: SUBSCRIPTION_TYPE.entity, + entityId: 'facebook', + userId: '63321072f27421740a80af24', + }, +]; + +describe('Process Entity Pending Notifications', () => { + it('Entity - Should process all pending notifications', () => { + const { notificationsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.entity, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then((result) => { + expect( + notificationsCollection.updateOne.callCount, + 'Mark notification item as processed' + ).to.be.equal(pendingNotifications.length); + + const sendEmailCalls = global.context.functions.execute + .getCalls() + .filter((call) => call.args[0] === 'sendEmail'); + + expect(sendEmailCalls.length, 'sendEmail function calls').to.be.equal( + uniquePendingNotifications.length + ); + + // Check that the emails are sent only once + for (let i = 0; i < sendEmailCalls.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + const sendEmailCallArgs = sendEmailCalls[i].args[1]; + + const userIds = subscriptions + .filter((s) => s.entityId === pendingNotification.entity_id) + .map((subscription) => subscription.userId); + + const isIncidentUpdate = pendingNotification.isUpdate; + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const entity = entities.find( + (entity) => entity.entity_id === pendingNotification.entity_id + ); + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: isIncidentUpdate + ? 'Update Incident for {{entityName}}' + : 'New Incident for {{entityName}}', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + entityName: entity.name, + entityUrl: `https://incidentdatabase.ai/entities/${entity.entity_id}`, + developers: buildEntityList(entities, incident['Alleged developer of AI system']), + deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList( + entities, + incident['Alleged harmed or nearly harmed parties'] + ), + }, + // Template value from function name sufix from "site/realm/functions/config.json" + templateId: isIncidentUpdate ? 'EntityIncidentUpdated' : 'NewEntityIncident', + }; + + expect(sendEmailCallArgs, 'Send email args').to.be.deep.equal(sendEmailParams); + } + + //No Rollbar error logs + expect( + global.context.functions.execute.getCalls().filter((call) => call.args[0] === 'logRollbar') + .length, + 'logRollbar function calls' + ).to.be.equal(0); + + expect(result, 'Notifications processed count').to.be.equal(pendingNotifications.length); + }); + }); + + it('Entity - Should send pending notifications', () => { + const { + notificationsCollection, + subscriptionsCollection, + incidentsCollection, + entitiesCollection, + } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.entity, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.secondCall.args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.entity, + }); + + expect(entitiesCollection.find.firstCall.args[0]).to.deep.equal({}); + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect(subscriptionsCollection.find.getCall(i).args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.entity, + entityId: pendingNotification.entity_id, + }); + + for (const subscription of subscriptions) { + expect(global.context.functions.execute).to.be.calledWith('getUser', { + userId: subscription.userId, + }); + } + + expect(incidentsCollection.findOne.getCall(i).args[0]).to.deep.equal({ + incident_id: pendingNotification.incident_id, + }); + + const userIds = subscriptions + .filter((s) => s.entityId === pendingNotification.entity_id) + .map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const entity = entities.find( + (entity) => entity.entity_id === pendingNotification.entity_id + ); + + const isIncidentUpdate = pendingNotification.isUpdate; + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: isIncidentUpdate + ? 'Update Incident for {{entityName}}' + : 'New Incident for {{entityName}}', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + entityName: entity.name, + entityUrl: `https://incidentdatabase.ai/entities/${entity.entity_id}`, + developers: buildEntityList(entities, incident['Alleged developer of AI system']), + deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList( + entities, + incident['Alleged harmed or nearly harmed parties'] + ), + }, + // Template value from function name sufix from "site/realm/functions/config.json" + templateId: isIncidentUpdate ? 'EntityIncidentUpdated' : 'NewEntityIncident', + }; + + expect(global.context.functions.execute, 'Send email').to.be.calledWith( + 'sendEmail', + sendEmailParams + ); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + }); + }); + + it('Entity - Should mark pending notifications as processed if there are no subscribers', () => { + const { notificationsCollection, subscriptionsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.entity, + pendingNotifications, + subscriptions: [], + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.getCall(0).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(notificationsCollection.find.getCall(1).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.entity, + }); + + expect(notificationsCollection.find.getCall(2).args[0]).to.deep.equal({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }); + + expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.submissionPromoted, + }); + + expect(global.context.functions.execute).not.to.be.called; + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect(subscriptionsCollection.find.getCall(i).args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.entity, + entityId: pendingNotification.entity_id, + }); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + + expect( + notificationsCollection.updateOne.getCalls().length, + 'Notifications marked as processed count' + ).to.be.equal(pendingNotifications.length); + }); + }); +}); diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processIncidentUpdatesNotifications.cy.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processIncidentUpdatesNotifications.cy.js new file mode 100644 index 0000000000..7a90a611c7 --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processIncidentUpdatesNotifications.cy.js @@ -0,0 +1,263 @@ +import { stubEverything } from './processNotificationsUtils'; + +const { SUBSCRIPTION_TYPE } = require('../../../../../src/utils/subscriptions'); + +const processNotifications = require('../../../../../../realm/functions/processNotifications'); + +const { recipients, reports, incidents } = require('./fixtures'); + +const pendingNotifications = [ + { + _id: '63616f82d0db19c07d081300', + type: 'incident-updated', + incident_id: 219, + processed: false, + }, + { + _id: '63616f82d0db19c07d081301', + type: 'new-report-incident', + incident_id: 219, + report_number: 2000, + processed: false, + }, + //Duplicated pending notification + { + _id: '63616f82d0db19c07d081302', + type: 'new-report-incident', + incident_id: 219, + report_number: 2000, + processed: false, + }, + { + _id: '63616f82d0db19c07d081303', + type: 'incident-updated', + incident_id: 219, + processed: false, + }, +]; + +const uniquePendingNotifications = pendingNotifications.slice(0, 2); + +const subscriptions = [ + { + userId: '63320ce63ec803072c9f5291', + type: SUBSCRIPTION_TYPE.incident, + incident_id: 219, + }, + { + userId: '63321072f27421740a80af22', + type: SUBSCRIPTION_TYPE.incident, + incident_id: 219, + }, +]; + +describe('Process Incident Updates Pending Notifications', () => { + it('Incident Updates - Should process all pending notifications', () => { + const { notificationsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.incident, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then((result) => { + expect( + notificationsCollection.updateOne.callCount, + 'Mark notification item as processed' + ).to.be.equal(pendingNotifications.length); + + const sendEmailCalls = global.context.functions.execute + .getCalls() + .filter((call) => call.args[0] === 'sendEmail'); + + expect(sendEmailCalls.length, 'sendEmail function calls').to.be.equal( + uniquePendingNotifications.length + ); + + // Check that the emails are sent only once + for (let i = 0; i < sendEmailCalls.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + const sendEmailCallArgs = sendEmailCalls[i].args[1]; + + const userIds = subscriptions + .filter((s) => s.incident_id === pendingNotification.incident_id) + .map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const newReportNumber = pendingNotification.report_number; + + const newReport = newReportNumber + ? reports.find((r) => r.report_number == pendingNotification.report_number) + : null; + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'Incident {{incidentId}} was updated', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + reportUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}#r${newReportNumber}`, + reportTitle: newReportNumber ? newReport.title : '', + reportAuthor: newReportNumber && newReport.authors[0] ? newReport.authors[0] : '', + }, + templateId: newReportNumber // Template value from function name sufix from "site/realm/functions/config.json" + ? 'NewReportAddedToAnIncident' + : 'IncidentUpdate', + }; + + expect(sendEmailCallArgs, 'Send email args').to.be.deep.equal(sendEmailParams); + } + + //No Rollbar error logs + expect( + global.context.functions.execute.getCalls().filter((call) => call.args[0] === 'logRollbar') + .length, + 'logRollbar function calls' + ).to.be.equal(0); + + expect(result, 'Notifications processed count').to.be.equal(pendingNotifications.length); + }); + }); + + it('Incident Updates - Should send pending notifications', () => { + const { notificationsCollection, subscriptionsCollection, incidentsCollection } = + stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.incident, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then(() => { + expect( + notificationsCollection.find.getCall(2).args[0], + 'Get pending notifications for Incident Updates' + ).to.deep.equal({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }); + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect( + subscriptionsCollection.find.getCall(i).args[0], + 'Get subscriptions for Incident' + ).to.deep.equal({ + type: SUBSCRIPTION_TYPE.incident, + incident_id: pendingNotification.incident_id, + }); + + for (const subscription of subscriptions) { + expect(global.context.functions.execute).to.be.calledWith('getUser', { + userId: subscription.userId, + }); + } + + expect(incidentsCollection.findOne.getCall(i).args[0]).to.deep.equal({ + incident_id: pendingNotification.incident_id, + }); + + const userIds = subscriptions + .filter((s) => s.incident_id === pendingNotification.incident_id) + .map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const newReportNumber = pendingNotification.report_number; + + const newReport = newReportNumber + ? reports.find((r) => r.report_number == pendingNotification.report_number) + : null; + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'Incident {{incidentId}} was updated', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + reportUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}#r${newReportNumber}`, + reportTitle: newReportNumber ? newReport.title : '', + reportAuthor: newReportNumber && newReport.authors[0] ? newReport.authors[0] : '', + }, + templateId: newReportNumber // Template value from function name sufix from "site/realm/functions/config.json" + ? 'NewReportAddedToAnIncident' + : 'IncidentUpdate', + }; + + expect(global.context.functions.execute, 'Send Email').to.be.calledWith( + 'sendEmail', + sendEmailParams + ); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + }); + }); + + it('Incident Updates - Should mark pending notifications as processed if there are no subscribers', () => { + const { notificationsCollection, subscriptionsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.incident, + pendingNotifications, + subscriptions: [], + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.getCall(0).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(notificationsCollection.find.getCall(1).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.entity, + }); + + expect(notificationsCollection.find.getCall(2).args[0]).to.deep.equal({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }); + + expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.submissionPromoted, + }); + + expect(global.context.functions.execute).not.to.be.called; + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect(subscriptionsCollection.find.getCall(i).args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.incident, + incident_id: pendingNotification.incident_id, + }); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + + expect( + notificationsCollection.updateOne.getCalls().length, + 'Notifications marked as processed count' + ).to.be.equal(pendingNotifications.length); + }); + }); +}); diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewIncidentsNotifications.cy.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewIncidentsNotifications.cy.js new file mode 100644 index 0000000000..6bd6397aa0 --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewIncidentsNotifications.cy.js @@ -0,0 +1,235 @@ +import { buildEntityList, stubEverything } from './processNotificationsUtils'; + +const { SUBSCRIPTION_TYPE } = require('../../../../../src/utils/subscriptions'); + +const processNotifications = require('../../../../../../realm/functions/processNotifications'); + +const { recipients, entities, incidents } = require('./fixtures'); + +const pendingNotifications = [ + { + _id: '63616f37d0db19c07d081100', + type: SUBSCRIPTION_TYPE.newIncidents, + incident_id: 217, + processed: false, + }, + { + _id: '63616f82d0db19c07d081101', + type: SUBSCRIPTION_TYPE.newIncidents, + incident_id: 218, + processed: false, + }, + //Duplicated pending notification + { + _id: '63616f82d0db19c07d081102', + type: SUBSCRIPTION_TYPE.newIncidents, + incident_id: 218, + processed: false, + }, +]; + +const uniquePendingNotifications = pendingNotifications.slice(0, 2); + +const subscriptions = [ + { + _id: '6356e39e863169c997309586', + type: SUBSCRIPTION_TYPE.newIncidents, + userId: '63320ce63ec803072c9f5291', + }, + { + _id: '6356e39e863169c997309586', + type: SUBSCRIPTION_TYPE.newIncidents, + userId: '63321072f27421740a80af22', + }, +]; + +describe('Process New Incident Pending Notifications', () => { + it('New Incidents - Should process all pending notifications', () => { + const { notificationsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.newIncidents, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then((result) => { + expect( + notificationsCollection.updateOne.callCount, + 'Mark notification item as processed' + ).to.be.equal(pendingNotifications.length); + + const sendEmailCalls = global.context.functions.execute + .getCalls() + .filter((call) => call.args[0] === 'sendEmail'); + + expect(sendEmailCalls.length, 'sendEmail function calls').to.be.equal( + uniquePendingNotifications.length + ); + + // Check that the emails are sent only once + for (let i = 0; i < sendEmailCalls.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + const sendEmailCallArgs = sendEmailCalls[i].args[1]; + + const userIds = subscriptions.map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'New Incident {{incidentId}} was created', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + developers: buildEntityList(entities, incident['Alleged developer of AI system']), + deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList( + entities, + incident['Alleged harmed or nearly harmed parties'] + ), + }, + templateId: 'NewIncident', // Template value from function name sufix from "site/realm/functions/config.json" + }; + + expect(sendEmailCallArgs, 'Send email args').to.be.deep.equal(sendEmailParams); + } + + //No Rollbar error logs + expect( + global.context.functions.execute.getCalls().filter((call) => call.args[0] === 'logRollbar') + .length, + 'logRollbar function calls' + ).to.be.equal(0); + + expect(result, 'Notifications processed count').to.be.equal(pendingNotifications.length); + }); + }); + + it('New Incidents - Should send pending notifications', () => { + const { notificationsCollection, subscriptionsCollection, incidentsCollection } = + stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.newIncidents, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.firstCall.args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(subscriptionsCollection.find.firstCall.args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + for (const subscription of subscriptions) { + expect(global.context.functions.execute).to.be.calledWith('getUser', { + userId: subscription.userId, + }); + } + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect(incidentsCollection.findOne.getCall(i).args[0]).to.deep.equal({ + incident_id: pendingNotification.incident_id, + }); + + const userIds = subscriptions.map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'New Incident {{incidentId}} was created', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + developers: buildEntityList(entities, incident['Alleged developer of AI system']), + deployers: buildEntityList(entities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList( + entities, + incident['Alleged harmed or nearly harmed parties'] + ), + }, + templateId: 'NewIncident', // Template value from function name sufix from "site/realm/functions/config.json" + }; + + expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + }); + }); + + it('New Incidents - Should mark pending notifications as processed if there are no subscribers', () => { + const { notificationsCollection, subscriptionsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.newIncidents, + pendingNotifications, + subscriptions: [], + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.getCall(0).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(notificationsCollection.find.getCall(1).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.entity, + }); + + expect(notificationsCollection.find.getCall(2).args[0]).to.deep.equal({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }); + + expect(subscriptionsCollection.find.getCall(0).args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.submissionPromoted, + }); + + expect(global.context.functions.execute).not.to.be.called; + + for (let i = 0; i < pendingNotifications.length; i++) { + const pendingNotification = pendingNotifications[i]; + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + + expect( + notificationsCollection.updateOne.getCalls().length, + 'Notifications marked as processed count' + ).to.be.equal(pendingNotifications.length); + }); + }); +}); diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewPromotionsNotifications.cy.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewPromotionsNotifications.cy.js new file mode 100644 index 0000000000..a3f6855336 --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNewPromotionsNotifications.cy.js @@ -0,0 +1,233 @@ +import { stubEverything } from './processNotificationsUtils'; + +const { SUBSCRIPTION_TYPE } = require('../../../../../src/utils/subscriptions'); + +const processNotifications = require('../../../../../../realm/functions/processNotifications'); + +const { recipients, incidents } = require('./fixtures'); + +const pendingNotifications = [ + { + _id: '63616f82d0db19c07d081400', + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: 217, + processed: false, + }, + { + _id: '63616f82d0db19c07d081401', + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: 218, + processed: false, + }, + //Duplicated pending notification + { + _id: '63616f82d0db19c07d081402', + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: 218, + processed: false, + }, +]; + +const uniquePendingNotifications = pendingNotifications.slice(0, 2); + +const subscriptions = [ + { + _id: '6356e39e863169c997309590', + type: SUBSCRIPTION_TYPE.submissionPromoted, + userId: '63320ce63ec803072c9f5291', + incident_id: 217, + }, + { + _id: '6356e39e863169c997309591', + type: SUBSCRIPTION_TYPE.submissionPromoted, + userId: '63321072f27421740a80af22', + incident_id: 218, + }, +]; + +describe('Process New Promotions Pending Notifications', () => { + it('New Promotions - Should process all pending notifications', () => { + const { notificationsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.submissionPromoted, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then((result) => { + expect( + notificationsCollection.updateOne.callCount, + 'Mark notification item as processed' + ).to.be.equal(pendingNotifications.length); + + const sendEmailCalls = global.context.functions.execute + .getCalls() + .filter((call) => call.args[0] === 'sendEmail'); + + expect(sendEmailCalls.length, 'sendEmail function calls').to.be.equal( + uniquePendingNotifications.length + ); + + // Check that the emails are sent only once + for (let i = 0; i < sendEmailCalls.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + const sendEmailCallArgs = sendEmailCalls[i].args[1]; + + const userIds = subscriptions + .filter((s) => s.incident_id === pendingNotification.incident_id) + .map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'Your submission has been approved!', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + }, + templateId: 'SubmissionApproved', // Template value from function name sufix from "site/realm/functions/config.json" + }; + + expect(sendEmailCallArgs, 'Send email args').to.be.deep.equal(sendEmailParams); + } + + //No Rollbar error logs + expect( + global.context.functions.execute.getCalls().filter((call) => call.args[0] === 'logRollbar') + .length, + 'logRollbar function calls' + ).to.be.equal(0); + + expect(result, 'Notifications processed count').to.be.equal(pendingNotifications.length); + }); + }); + + it('New Promotions - Should send pending submissions promoted notifications', () => { + const { notificationsCollection, subscriptionsCollection, incidentsCollection } = + stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.submissionPromoted, + pendingNotifications, + subscriptions, + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.submissionPromoted, + }); + + for (const subscription of subscriptions) { + expect(global.context.functions.execute).to.be.calledWith('getUser', { + userId: subscription.userId, + }); + } + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect( + subscriptionsCollection.find.getCall(i).args[0], + 'Get subscriptions for Incident' + ).to.deep.equal({ + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: pendingNotification.incident_id, + }); + + expect(incidentsCollection.findOne.getCall(i).args[0], 'Find incident').to.deep.equal({ + incident_id: pendingNotification.incident_id, + }); + + const userIds = subscriptions + .filter((s) => s.incident_id === pendingNotification.incident_id) + .map((subscription) => subscription.userId); + + const incident = incidents.find((i) => i.incident_id == pendingNotification.incident_id); + + const sendEmailParams = { + recipients: recipients.filter((r) => userIds.includes(r.userId)), + subject: 'Your submission has been approved!', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${pendingNotification.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + }, + templateId: 'SubmissionApproved', // Template value from function name sufix from "site/realm/functions/config.json" + }; + + expect(global.context.functions.execute).to.be.calledWith('sendEmail', sendEmailParams); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + }); + }); + + it('New Promotions - Should mark pending notifications as processed if there are no subscribers', () => { + const { notificationsCollection, subscriptionsCollection } = stubEverything({ + subscriptionType: SUBSCRIPTION_TYPE.submissionPromoted, + pendingNotifications, + subscriptions: [], + }); + + cy.wrap(processNotifications()).then(() => { + expect(notificationsCollection.find.getCall(0).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.newIncidents, + }); + + expect(notificationsCollection.find.getCall(1).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.entity, + }); + + expect(notificationsCollection.find.getCall(2).args[0]).to.deep.equal({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }); + + expect(notificationsCollection.find.getCall(3).args[0]).to.deep.equal({ + processed: false, + type: SUBSCRIPTION_TYPE.submissionPromoted, + }); + + expect(global.context.functions.execute).not.to.be.called; + + for (let i = 0; i < uniquePendingNotifications.length; i++) { + const pendingNotification = uniquePendingNotifications[i]; + + expect(subscriptionsCollection.find.getCall(i).args[0]).to.deep.equal({ + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: pendingNotification.incident_id, + }); + + expect(notificationsCollection.updateOne.getCall(i).args[0]).to.deep.equal({ + _id: pendingNotification._id, + }); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set.processed).to.be.equal( + true + ); + expect(notificationsCollection.updateOne.getCall(i).args[1].$set).to.have.ownProperty( + 'sentDate' + ); + } + + expect( + notificationsCollection.updateOne.getCalls().length, + 'Notifications marked as processed count' + ).to.be.equal(pendingNotifications.length); + }); + }); +}); diff --git a/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNotificationsUtils.js b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNotificationsUtils.js new file mode 100644 index 0000000000..2c3d7df85d --- /dev/null +++ b/site/gatsby-site/cypress/e2e/unit/functions/processNotifications/processNotificationsUtils.js @@ -0,0 +1,239 @@ +const { SUBSCRIPTION_TYPE } = require('../../../../../src/utils/subscriptions'); + +const { recipients, entities, incidents, reports } = require('./fixtures'); + +export const buildEntityList = (allEntities, entityIds) => { + const entityNames = entityIds.map((entityId) => { + const entity = allEntities.find((entity) => entity.entity_id === entityId); + + return entity + ? `${entity.name}` + : ''; + }); + + if (entityNames.length < 3) { + return entityNames.join(' and '); + } + + return `${entityNames.slice(0, -1).join(', ')}, and ${entityNames[entityNames.length - 1]}`; +}; + +export const stubEverything = ({ subscriptionType, pendingNotifications, subscriptions }) => { + const notificationsCollection = { + find: (() => { + const stub = cy.stub(); + + // Initiate empty stubs for all types + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.newIncidents }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.newIncidents})`) + .returns({ toArray: () => [] }); + + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.entity }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.entity})`) + .returns({ toArray: () => [] }); + + stub + .withArgs({ processed: false, type: { $in: ['new-report-incident', 'incident-updated'] } }) + .as(`notifications.find('new-report-incident', 'incident-updated')`) + .returns({ toArray: () => [] }); + + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.submissionPromoted }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.submissionPromoted})`) + .returns({ toArray: () => [] }); + + // Override stubs for specific types + switch (subscriptionType) { + case SUBSCRIPTION_TYPE.newIncidents: + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.newIncidents }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.newIncidents})`) + .returns({ toArray: () => pendingNotifications }); + break; + + case SUBSCRIPTION_TYPE.entity: + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.entity }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.entity})`) + .returns({ toArray: () => pendingNotifications }); + break; + + case SUBSCRIPTION_TYPE.incident: + stub + .withArgs({ + processed: false, + type: { $in: ['new-report-incident', 'incident-updated'] }, + }) + .as(`notifications.find('new-report-incident', 'incident-updated')`) + .returns({ toArray: () => pendingNotifications }); + break; + + case SUBSCRIPTION_TYPE.submissionPromoted: + stub + .withArgs({ processed: false, type: SUBSCRIPTION_TYPE.submissionPromoted }) + .as(`notifications.find(${SUBSCRIPTION_TYPE.submissionPromoted})`) + .returns({ toArray: () => pendingNotifications }); + break; + } + + return stub; + })(), + updateOne: cy.stub().as('notifications.updateOne').resolves(), + }; + + const subscriptionsCollection = { + find: (() => { + const stub = cy.stub(); + + switch (subscriptionType) { + case SUBSCRIPTION_TYPE.newIncidents: + stub + .withArgs({ type: SUBSCRIPTION_TYPE.newIncidents }) + .as(`subscriptions.find("${SUBSCRIPTION_TYPE.newIncidents}")`) + .returns({ toArray: () => subscriptions }); + break; + + case SUBSCRIPTION_TYPE.entity: + for (const pendingNotification of pendingNotifications) { + stub + .withArgs({ type: SUBSCRIPTION_TYPE.entity, entityId: pendingNotification.entity_id }) + .as( + `subscriptions.find("${SUBSCRIPTION_TYPE.entity}", "${pendingNotification.entity_id}")` + ) + .returns({ + toArray: () => + subscriptions.filter((s) => s.entityId === pendingNotification.entity_id), + }); + } + break; + + case SUBSCRIPTION_TYPE.incident: + for (const pendingNotification of pendingNotifications) { + stub + .withArgs({ + type: SUBSCRIPTION_TYPE.incident, + incident_id: pendingNotification.incident_id, + }) + .as( + `subscriptions.find("${SUBSCRIPTION_TYPE.incident}", "${pendingNotification.incident_id}")` + ) + .returns({ + toArray: () => + subscriptions.filter((s) => s.incident_id === pendingNotification.incident_id), + }); + } + break; + + case SUBSCRIPTION_TYPE.submissionPromoted: + for (const pendingNotification of pendingNotifications) { + stub + .withArgs({ + type: SUBSCRIPTION_TYPE.submissionPromoted, + incident_id: pendingNotification.incident_id, + }) + .as( + `subscriptions.find("${SUBSCRIPTION_TYPE.submissionPromoted}", "${pendingNotification.incident_id}")` + ) + .returns({ + toArray: () => + subscriptions.filter((s) => s.incident_id === pendingNotification.incident_id), + }); + } + break; + } + + return stub; + })(), + }; + + const incidentsCollection = { + findOne: (() => { + const stub = cy.stub(); + + for (let index = 0; index < incidents.length; index++) { + const incident = incidents[index]; + + stub + .withArgs({ incident_id: incident.incident_id }) + .as(`incidents.findOne(${incident.incident_id})`) + .returns(incidents.find((i) => i.incident_id == incident.incident_id)); + } + + return stub; + })(), + }; + + const reportsCollection = { + findOne: (() => { + const stub = cy.stub(); + + for (let index = 0; index < reports.length; index++) { + const report = reports[index]; + + stub + .withArgs({ report_number: report.report_number }) + .as(`reports.findOne(${report.report_number})`) + .returns(reports.find((r) => r.report_number == report.report_number)); + } + + return stub; + })(), + }; + + const entitiesCollection = { + find: cy.stub().returns({ + toArray: cy.stub().as('entities.find').resolves(entities), + }), + }; + + global.context = { + // @ts-ignore + services: { + get: cy.stub().returns({ + db: cy.stub().returns({ + collection: (() => { + const stub = cy.stub(); + + stub.withArgs('notifications').returns(notificationsCollection); + stub.withArgs('subscriptions').returns(subscriptionsCollection); + stub.withArgs('incidents').returns(incidentsCollection); + stub.withArgs('entities').returns(entitiesCollection); + stub.withArgs('reports').returns(reportsCollection); + + return stub; + })(), + }), + }), + }, + functions: { + execute: (() => { + const stub = cy.stub(); + + for (const user of recipients) { + stub + .withArgs('getUser', { userId: user.userId }) + .as(`getUser(${user.userId})`) + .returns(recipients.find((r) => r.userId == user.userId)); + } + + stub.withArgs('sendEmail').as('sendEmail').returns({ statusCode: 200 }); + + stub.withArgs('logRollbar').as('logRollbar').returns({ statusCode: 200 }); + + return stub; + })(), + }, + }; + + global.BSON = { Int32: (x) => x }; + + return { + notificationsCollection, + subscriptionsCollection, + incidentsCollection, + entitiesCollection, + reportsCollection, + }; +}; diff --git a/site/gatsby-site/src/components/submissions/SubmissionEditForm.js b/site/gatsby-site/src/components/submissions/SubmissionEditForm.js index 3fa9785a8e..a408c69103 100644 --- a/site/gatsby-site/src/components/submissions/SubmissionEditForm.js +++ b/site/gatsby-site/src/components/submissions/SubmissionEditForm.js @@ -39,8 +39,6 @@ const SubmissionEditForm = ({ handleSubmit, saving, setSaving, userLoading, user const localizedPath = useLocalizePath(); - const [subscribeToNewSubmissionPromotionMutation] = useMutation(UPSERT_SUBSCRIPTION); - useEffect(() => { if (!isEmpty(touched)) { setSaving(true); @@ -220,27 +218,6 @@ const SubmissionEditForm = ({ handleSubmit, saving, setSaving, userLoading, user await subscribeToNewReports(incident_id); - if (values.user) { - await subscribeToNewSubmissionPromotionMutation({ - variables: { - query: { - type: SUBSCRIPTION_TYPE.submissionPromoted, - userId: { userId: values.user.userId }, - incident_id: { incident_id: incident_id }, - }, - subscription: { - type: SUBSCRIPTION_TYPE.submissionPromoted, - userId: { - link: values.user.userId, - }, - incident_id: { - link: incident_id, - }, - }, - }, - }); - } - addToast({ message: ( diff --git a/site/realm/functions/processNotifications.js b/site/realm/functions/processNotifications.js index 5b495d727c..3ee9b13f7c 100644 --- a/site/realm/functions/processNotifications.js +++ b/site/realm/functions/processNotifications.js @@ -69,41 +69,50 @@ exports = async function () { const userIds = subscriptionsToNewIncidents.map((subscription) => subscription.userId); - const recipients = await getRecipients(userIds); + const uniqueUserIds = [...new Set(userIds)]; + + const recipients = await getRecipients(uniqueUserIds); + + let uniqueNotifications = []; for (const pendingNotification of pendingNotificationsToNewIncidents) { // Mark the notification as processed before sending the email await markNotificationsAsProcessed(notificationsCollection, [pendingNotification]); - try { - const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); - - //Send email notification - const sendEmailParams = { - recipients, - subject: 'New Incident {{incidentId}} was created', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - developers: buildEntityList(allEntities, incident['Alleged developer of AI system']), - deployers: buildEntityList(allEntities, incident['Alleged deployer of AI system']), - entitiesHarmed: buildEntityList(allEntities, incident['Alleged harmed or nearly harmed parties']), - }, - templateId: 'NewIncident' // Template value from function name sufix from "site/realm/functions/config.json" - }; - - await context.functions.execute('sendEmail', sendEmailParams); - - } catch (error) { - // If there is an error sending the email > Mark the notification as not processed - await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); - - error.message = `[Process Pending Notifications: New Incidents]: ${error.message}`; - context.functions.execute('logRollbar', { error }); + // Send only one email per Incident + if (!uniqueNotifications.includes(pendingNotification.incident_id)) { + uniqueNotifications.push(pendingNotification.incident_id); + + try { + const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); + + //Send email notification + const sendEmailParams = { + recipients, + subject: 'New Incident {{incidentId}} was created', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + developers: buildEntityList(allEntities, incident['Alleged developer of AI system']), + deployers: buildEntityList(allEntities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList(allEntities, incident['Alleged harmed or nearly harmed parties']), + }, + templateId: 'NewIncident' // Template value from function name sufix from "site/realm/functions/config.json" + }; + + await context.functions.execute('sendEmail', sendEmailParams); + + } catch (error) { + // If there is an error sending the email > Mark the notification as not processed + await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); + + error.message = `[Process Pending Notifications: New Incidents]: ${error.message}`; + context.functions.execute('logRollbar', { error }); + } } } @@ -133,61 +142,71 @@ exports = async function () { const allEntities = await entitiesCollection.find({}).toArray(); + let uniqueNotifications = []; + for (const pendingNotification of pendingNotificationsToNewEntityIncidents) { // Mark the notification as processed before sending the email await markNotificationsAsProcessed(notificationsCollection, [pendingNotification]); - try { - const subscriptionsToNewEntityIncidents = await subscriptionsCollection.find({ - type: 'entity', - entityId: pendingNotification.entity_id - }).toArray(); + // Process each entity only once + if (!uniqueNotifications.includes(pendingNotification.entity_id)) { + uniqueNotifications.push(pendingNotification.entity_id); - // Process subscriptions to New Entity Incidents - if (subscriptionsToNewEntityIncidents.length > 0) { + try { - const userIds = subscriptionsToNewEntityIncidents.map((subscription) => subscription.userId); + const subscriptionsToNewEntityIncidents = await subscriptionsCollection.find({ + type: 'entity', + entityId: pendingNotification.entity_id + }).toArray(); - const recipients = await getRecipients(userIds); + // Process subscriptions to New Entity Incidents + if (subscriptionsToNewEntityIncidents.length > 0) { - const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); + const userIds = subscriptionsToNewEntityIncidents.map((subscription) => subscription.userId); - const entity = allEntities.find(entity => entity.entity_id === pendingNotification.entity_id); + const uniqueUserIds = [...new Set(userIds)]; - const isIncidentUpdate = pendingNotification.isUpdate; + const recipients = await getRecipients(uniqueUserIds); - //Send email notification - const sendEmailParams = { - recipients, - subject: isIncidentUpdate ? 'Update Incident for {{entityName}}' - : 'New Incident for {{entityName}}', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - entityName: entity.name, - entityUrl: `https://incidentdatabase.ai/entities/${entity.entity_id}`, - developers: buildEntityList(allEntities, incident['Alleged developer of AI system']), - deployers: buildEntityList(allEntities, incident['Alleged deployer of AI system']), - entitiesHarmed: buildEntityList(allEntities, incident['Alleged harmed or nearly harmed parties']), - }, - // Template value from function name sufix from "site/realm/functions/config.json" - templateId: isIncidentUpdate ? 'EntityIncidentUpdated' : 'NewEntityIncident' - }; + const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); - await context.functions.execute('sendEmail', sendEmailParams); + const entity = allEntities.find(entity => entity.entity_id === pendingNotification.entity_id); - console.log(`New "${entity.name}" Entity Incidents: pending notification was processed.`); - } - } catch (error) { - // If there is an error sending the email > Mark the notification as not processed - await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); + const isIncidentUpdate = pendingNotification.isUpdate; - error.message = `[Process Pending Notifications: New Entity Incidents]: ${error.message}`; - context.functions.execute('logRollbar', { error }); + //Send email notification + const sendEmailParams = { + recipients, + subject: isIncidentUpdate ? 'Update Incident for {{entityName}}' + : 'New Incident for {{entityName}}', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + entityName: entity.name, + entityUrl: `https://incidentdatabase.ai/entities/${entity.entity_id}`, + developers: buildEntityList(allEntities, incident['Alleged developer of AI system']), + deployers: buildEntityList(allEntities, incident['Alleged deployer of AI system']), + entitiesHarmed: buildEntityList(allEntities, incident['Alleged harmed or nearly harmed parties']), + }, + // Template value from function name sufix from "site/realm/functions/config.json" + templateId: isIncidentUpdate ? 'EntityIncidentUpdated' : 'NewEntityIncident' + }; + + await context.functions.execute('sendEmail', sendEmailParams); + + console.log(`New "${entity.name}" Entity Incidents: pending notification was processed.`); + } + } catch (error) { + // If there is an error sending the email > Mark the notification as not processed + await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); + + error.message = `[Process Pending Notifications: New Entity Incidents]: ${error.message}`; + context.functions.execute('logRollbar', { error }); + } } } } @@ -210,11 +229,17 @@ exports = async function () { result += pendingNotificationsToIncidentUpdates.length; + let uniqueNotifications = []; + for (const pendingNotification of pendingNotificationsToIncidentUpdates) { // Mark the notification as processed before sending the email await markNotificationsAsProcessed(notificationsCollection, [pendingNotification]); + // Process each Incident only once + if (!uniqueNotifications.some(n => n.incident_id === pendingNotification.incident_id && n.type === pendingNotification.type)) { + uniqueNotifications.push(pendingNotification); + try { const subscriptionsToIncidentUpdates = await subscriptionsCollection.find({ type: 'incident', @@ -226,7 +251,9 @@ exports = async function () { const userIds = subscriptionsToIncidentUpdates.map((subscription) => subscription.userId); - const recipients = await getRecipients(userIds); + const uniqueUserIds = [...new Set(userIds)]; + + const recipients = await getRecipients(uniqueUserIds); const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); @@ -261,6 +288,7 @@ exports = async function () { error.message = `[Process Pending Notifications: Incidents Updates]: ${error.message}`; context.functions.execute('logRollbar', { error }); } + } } } else { @@ -278,62 +306,68 @@ exports = async function () { // Finds all pending notifications to New Promotions const pendingNotificationsToNewPromotions = await notificationsCollection.find({ processed: false, type: 'submission-promoted' }).toArray(); - // Gets all incident ids from pending notifications to New Promotions - const pendingNotificationsIncidentIds = pendingNotificationsToNewPromotions.map((notification) => notification.incident_id); - if (pendingNotificationsToNewPromotions.length > 0) { result += pendingNotificationsToNewPromotions.length; - // Finds all subscriptions to New Promotions for those new incidents - const subscriptionsToNewPromotions = await subscriptionsCollection.find({ type: 'submission-promoted', incident_id: { $in: pendingNotificationsIncidentIds } }).toArray(); + let uniqueNotifications = []; - // Process subscriptions to New Incidents - if (subscriptionsToNewPromotions.length > 0) { + for (const pendingNotification of pendingNotificationsToNewPromotions) { - const userIds = subscriptionsToNewPromotions.map((subscription) => subscription.userId); + // Mark the notification as processed before sending the email + await markNotificationsAsProcessed(notificationsCollection, [pendingNotification]); - const recipients = await getRecipients(userIds); + // Process each Incident only once + if (!uniqueNotifications.includes(pendingNotification.incident_id)) { + uniqueNotifications.push(pendingNotification.incident_id); - for (const pendingNotification of pendingNotificationsToNewPromotions) { + // Finds all subscriptions to New Promotions for this Incident + const subscriptionsToNewPromotions = await subscriptionsCollection.find({ + type: 'submission-promoted', + incident_id: pendingNotification.incident_id + }).toArray(); - // Mark the notification as processed before sending the email - await markNotificationsAsProcessed(notificationsCollection, [pendingNotification]); + // Process subscriptions to New Incidents + if (subscriptionsToNewPromotions.length > 0) { - try { - const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); + const userIds = subscriptionsToNewPromotions.map((subscription) => subscription.userId); - //Send email notification - const sendEmailParams = { - recipients, - subject: 'Your submission has been approved!', - dynamicData: { - incidentId: `${incident.incident_id}`, - incidentTitle: incident.title, - incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, - incidentDescription: incident.description, - incidentDate: incident.date, - }, - templateId: 'SubmissionApproved' // Template value from function name sufix from "site/realm/functions/config.json" - }; + const uniqueUserIds = [...new Set(userIds)]; - await context.functions.execute('sendEmail', sendEmailParams); + const recipients = await getRecipients(uniqueUserIds); - } catch (error) { - // If there is an error sending the email > Mark the notification as not processed - await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); + try { + const incident = await incidentsCollection.findOne({ incident_id: pendingNotification.incident_id }); - error.message = `[Process Pending Notifications: Submission Promoted]: ${error.message}`; - context.functions.execute('logRollbar', { error }); + //Send email notification + const sendEmailParams = { + recipients, + subject: 'Your submission has been approved!', + dynamicData: { + incidentId: `${incident.incident_id}`, + incidentTitle: incident.title, + incidentUrl: `https://incidentdatabase.ai/cite/${incident.incident_id}`, + incidentDescription: incident.description, + incidentDate: incident.date, + }, + templateId: 'SubmissionApproved' // Template value from function name sufix from "site/realm/functions/config.json" + }; + + await context.functions.execute('sendEmail', sendEmailParams); + + console.log(`Promoted notification for incident ${incident.incident_id} was processed.`); + + } catch (error) { + // If there is an error sending the email > Mark the notification as not processed + await markNotificationsAsNotProcessed(notificationsCollection, [pendingNotification]); + + error.message = `[Process Pending Notifications: Submission Promoted]: ${error.message}`; + context.functions.execute('logRollbar', { error }); + } } } - - console.log(`New Promotions: ${pendingNotificationsToNewPromotions.length} pending notifications were processed.`); - } - else { - // If there are no subscribers to New Incidents (edge case) > Mark all pending notifications as processed - await markNotificationsAsProcessed(notificationsCollection, pendingNotificationsToNewPromotions); } + console.log(`New Promotions: ${pendingNotificationsToNewPromotions.length} pending notifications were processed.`); } else { console.log('Submission Promoted: No pending notifications to process.');