diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js index 64ee2009c..f3f2e5c93 100644 --- a/backend/src/db/factories.js +++ b/backend/src/db/factories.js @@ -70,6 +70,7 @@ Factory.define('basicUser') termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', allowEmbedIframes: false, showShoutsPublicly: false, + sendNotificationEmails: true, locale: 'en', }) .attr('slug', ['slug', 'name'], (slug, name) => { diff --git a/backend/src/db/migrations/20210923140939-add-sendNotificationEmails-property-to-all-users.js b/backend/src/db/migrations/20210923140939-add-sendNotificationEmails-property-to-all-users.js new file mode 100644 index 000000000..0d1f4fb91 --- /dev/null +++ b/backend/src/db/migrations/20210923140939-add-sendNotificationEmails-property-to-all-users.js @@ -0,0 +1,59 @@ +import { getDriver } from '../../db/neo4j' + +export const description = '' + +export async function up(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run( + ` + MATCH (user:User) + SET user.sendNotificationEmails = true + RETURN user {.*} + `, + ) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} + +export async function down(next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run( + ` + MATCH (user:User) + REMOVE user.sendNotificationEmails + RETURN user {.*} + `, + ) + await transaction.commit() + next() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + session.close() + } +} diff --git a/backend/src/middleware/helpers/email/sendMail.js b/backend/src/middleware/helpers/email/sendMail.js new file mode 100644 index 000000000..a25938a14 --- /dev/null +++ b/backend/src/middleware/helpers/email/sendMail.js @@ -0,0 +1,39 @@ +import CONFIG from '../../../config' +import nodemailer from 'nodemailer' +import { htmlToText } from 'nodemailer-html-to-text' + +const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT +const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD + +let sendMailCallback = async () => {} +if (!hasEmailConfig) { + if (!CONFIG.TEST) { + // eslint-disable-next-line no-console + console.log('Warning: Middlewares will not try to send mails.') + } +} else { + sendMailCallback = async (templateArgs) => { + const transporter = nodemailer.createTransport({ + host: CONFIG.SMTP_HOST, + port: CONFIG.SMTP_PORT, + ignoreTLS: CONFIG.SMTP_IGNORE_TLS, + secure: CONFIG.SMTP_SECURE, // true for 465, false for other ports + auth: hasAuthData && { + user: CONFIG.SMTP_USERNAME, + pass: CONFIG.SMTP_PASSWORD, + }, + }) + + transporter.use( + 'compile', + htmlToText({ + ignoreImage: true, + wordwrap: false, + }), + ) + + await transporter.sendMail(templateArgs) + } +} + +export const sendMail = sendMailCallback diff --git a/backend/src/middleware/email/templateBuilder.js b/backend/src/middleware/helpers/email/templateBuilder.js similarity index 64% rename from backend/src/middleware/email/templateBuilder.js rename to backend/src/middleware/helpers/email/templateBuilder.js index 872b86b29..8098b38fe 100644 --- a/backend/src/middleware/email/templateBuilder.js +++ b/backend/src/middleware/helpers/email/templateBuilder.js @@ -1,18 +1,23 @@ import mustache from 'mustache' -import CONFIG from '../../config' -import logosWebapp from '../../config/logos.js' +import CONFIG from '../../../config' +import metadata from '../../../config/metadata.js' +import logosWebapp from '../../../config/logos.js' import * as templates from './templates' +import * as templatesEN from './templates/en' +import * as templatesDE from './templates/de' const from = CONFIG.EMAIL_DEFAULT_SENDER const welcomeImageUrl = new URL(logosWebapp.LOGO_WELCOME_PATH, CONFIG.CLIENT_URI) const defaultParams = { - supportUrl: CONFIG.SUPPORT_URL, - APPLICATION_NAME: CONFIG.APPLICATION_NAME, - ORGANIZATION_URL: CONFIG.ORGANIZATION_URL, welcomeImageUrl, + APPLICATION_NAME: CONFIG.APPLICATION_NAME, + ORGANIZATION_NAME: metadata.ORGANIZATION_NAME, + ORGANIZATION_URL: CONFIG.ORGANIZATION_URL, + supportUrl: CONFIG.SUPPORT_URL, } +const englishHint = 'English version below!' export const signupTemplate = ({ email, nonce, inviteCode = null }) => { const subject = `Willkommen, Bienvenue, Welcome to ${CONFIG.APPLICATION_NAME}!` @@ -33,7 +38,7 @@ export const signupTemplate = ({ email, nonce, inviteCode = null }) => { subject, html: mustache.render( templates.layout, - { ...defaultParams, actionUrl, nonce, subject }, + { ...defaultParams, englishHint, actionUrl, nonce, subject }, { content: templates.signup }, ), } @@ -51,7 +56,7 @@ export const emailVerificationTemplate = ({ email, nonce, name }) => { subject, html: mustache.render( templates.layout, - { ...defaultParams, actionUrl, name, nonce, subject }, + { ...defaultParams, englishHint, actionUrl, name, nonce, subject }, { content: templates.emailVerification }, ), } @@ -69,7 +74,7 @@ export const resetPasswordTemplate = ({ email, nonce, name }) => { subject, html: mustache.render( templates.layout, - { ...defaultParams, actionUrl, name, nonce, subject }, + { ...defaultParams, englishHint, actionUrl, name, nonce, subject }, { content: templates.passwordReset }, ), } @@ -85,8 +90,35 @@ export const wrongAccountTemplate = ({ email }) => { subject, html: mustache.render( templates.layout, - { ...defaultParams, actionUrl, supportUrl: CONFIG.SUPPORT_URL, welcomeImageUrl }, + { ...defaultParams, englishHint, actionUrl }, { content: templates.wrongAccount }, ), } } + +export const notificationTemplate = ({ email, notification }) => { + const actionUrl = new URL('/notifications', CONFIG.CLIENT_URI) + const renderParams = { ...defaultParams, name: notification.to.name, actionUrl } + let content + switch (notification.to.locale) { + case 'de': + content = templatesDE.notification + break + case 'en': + content = templatesEN.notification + break + + default: + content = templatesEN.notification + break + } + const subjectUnrendered = content.split('\n')[0].split('"')[1] + const subject = mustache.render(subjectUnrendered, renderParams, {}) + + return { + from, + to: email, + subject, + html: mustache.render(templates.layout, renderParams, { content }), + } +} diff --git a/backend/src/middleware/helpers/email/templates/de/index.js b/backend/src/middleware/helpers/email/templates/de/index.js new file mode 100644 index 000000000..0f9d13c36 --- /dev/null +++ b/backend/src/middleware/helpers/email/templates/de/index.js @@ -0,0 +1,6 @@ +import fs from 'fs' +import path from 'path' + +const readFile = (fileName) => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') + +export const notification = readFile('./notification.html') diff --git a/backend/src/middleware/helpers/email/templates/de/notification.html b/backend/src/middleware/helpers/email/templates/de/notification.html new file mode 100644 index 000000000..b3c60b26e --- /dev/null +++ b/backend/src/middleware/helpers/email/templates/de/notification.html @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/email/templates/emailVerification.html b/backend/src/middleware/helpers/email/templates/emailVerification.html similarity index 100% rename from backend/src/middleware/email/templates/emailVerification.html rename to backend/src/middleware/helpers/email/templates/emailVerification.html diff --git a/backend/src/middleware/helpers/email/templates/en/index.js b/backend/src/middleware/helpers/email/templates/en/index.js new file mode 100644 index 000000000..0f9d13c36 --- /dev/null +++ b/backend/src/middleware/helpers/email/templates/en/index.js @@ -0,0 +1,6 @@ +import fs from 'fs' +import path from 'path' + +const readFile = (fileName) => fs.readFileSync(path.join(__dirname, fileName), 'utf-8') + +export const notification = readFile('./notification.html') diff --git a/backend/src/middleware/helpers/email/templates/en/notification.html b/backend/src/middleware/helpers/email/templates/en/notification.html new file mode 100644 index 000000000..58cbffd6f --- /dev/null +++ b/backend/src/middleware/helpers/email/templates/en/notification.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/middleware/email/templates/index.js b/backend/src/middleware/helpers/email/templates/index.js similarity index 100% rename from backend/src/middleware/email/templates/index.js rename to backend/src/middleware/helpers/email/templates/index.js diff --git a/backend/src/middleware/email/templates/layout.html b/backend/src/middleware/helpers/email/templates/layout.html similarity index 94% rename from backend/src/middleware/email/templates/layout.html rename to backend/src/middleware/helpers/email/templates/layout.html index da2053a93..0c68d6309 100644 --- a/backend/src/middleware/email/templates/layout.html +++ b/backend/src/middleware/helpers/email/templates/layout.html @@ -159,7 +159,7 @@ -

English version below!

+

{{englishHint}}

{{> content}} @@ -169,10 +169,11 @@ -

- {{ORGANIZATION_NAME}} -
{{ORGANIZATION_URL}}
-

+
+ {{ORGANIZATION_NAME}} +
+
+
diff --git a/backend/src/middleware/email/templates/resetPassword.html b/backend/src/middleware/helpers/email/templates/resetPassword.html similarity index 100% rename from backend/src/middleware/email/templates/resetPassword.html rename to backend/src/middleware/helpers/email/templates/resetPassword.html diff --git a/backend/src/middleware/email/templates/signup.html b/backend/src/middleware/helpers/email/templates/signup.html similarity index 100% rename from backend/src/middleware/email/templates/signup.html rename to backend/src/middleware/helpers/email/templates/signup.html diff --git a/backend/src/middleware/email/templates/wrongAccount.html b/backend/src/middleware/helpers/email/templates/wrongAccount.html similarity index 100% rename from backend/src/middleware/email/templates/wrongAccount.html rename to backend/src/middleware/helpers/email/templates/wrongAccount.html diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 592e25a60..22e92e1a3 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -12,7 +12,7 @@ import orderBy from './orderByMiddleware' import validation from './validation/validationMiddleware' import notifications from './notifications/notificationsMiddleware' import hashtags from './hashtags/hashtagsMiddleware' -import email from './email/emailMiddleware' +import login from './login/loginMiddleware' import sentry from './sentryMiddleware' import languages from './languages/languages' import userInteractions from './userInteractions' @@ -26,7 +26,7 @@ export default (schema) => { validation, sluggify, excerpt, - email, + login, notifications, hashtags, softDelete, @@ -46,7 +46,7 @@ export default (schema) => { 'sluggify', 'languages', 'excerpt', - 'email', + 'login', 'notifications', 'hashtags', 'softDelete', diff --git a/backend/src/middleware/email/emailMiddleware.js b/backend/src/middleware/login/loginMiddleware.js similarity index 56% rename from backend/src/middleware/email/emailMiddleware.js rename to backend/src/middleware/login/loginMiddleware.js index 571b733d5..b7fe0239a 100644 --- a/backend/src/middleware/email/emailMiddleware.js +++ b/backend/src/middleware/login/loginMiddleware.js @@ -1,46 +1,10 @@ -import CONFIG from '../../config' -import nodemailer from 'nodemailer' -import { htmlToText } from 'nodemailer-html-to-text' +import { sendMail } from '../helpers/email/sendMail' import { signupTemplate, resetPasswordTemplate, wrongAccountTemplate, emailVerificationTemplate, -} from './templateBuilder' - -const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT -const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD - -let sendMail = () => {} -if (!hasEmailConfig) { - if (!CONFIG.TEST) { - // eslint-disable-next-line no-console - console.log('Warning: Email middleware will not try to send mails.') - } -} else { - sendMail = async (templateArgs) => { - const transporter = nodemailer.createTransport({ - host: CONFIG.SMTP_HOST, - port: CONFIG.SMTP_PORT, - ignoreTLS: CONFIG.SMTP_IGNORE_TLS, - secure: CONFIG.SMTP_SECURE, // true for 465, false for other ports - auth: hasAuthData && { - user: CONFIG.SMTP_USERNAME, - pass: CONFIG.SMTP_PASSWORD, - }, - }) - - transporter.use( - 'compile', - htmlToText({ - ignoreImage: true, - wordwrap: false, - }), - ) - - await transporter.sendMail(templateArgs) - } -} +} from '../helpers/email/templateBuilder' const sendSignupMail = async (resolve, root, args, context, resolveInfo) => { const { inviteCode } = args diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index c76b9ca0e..2bc53ab7c 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -1,21 +1,64 @@ +import { pubsub, NOTIFICATION_ADDED } from '../../server' import extractMentionedUsers from './mentions/extractMentionedUsers' import { validateNotifyUsers } from '../validation/validationMiddleware' -import { pubsub, NOTIFICATION_ADDED } from '../../server' +import { sendMail } from '../helpers/email/sendMail' +import { notificationTemplate } from '../helpers/email/templateBuilder' -const publishNotifications = async (...promises) => { - const notifications = await Promise.all(promises) - notifications - .flat() - .forEach((notificationAdded) => pubsub.publish(NOTIFICATION_ADDED, { notificationAdded })) +const queryNotificationEmails = async (context, notificationUserIds) => { + if (!(notificationUserIds && notificationUserIds.length)) return [] + const userEmailCypher = ` + MATCH (user: User) + // blocked users are filtered out from notifications already + WHERE user.id in $notificationUserIds + WITH user + MATCH (user)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress) + RETURN emailAddress {.email} + ` + const session = context.driver.session() + const writeTxResultPromise = session.writeTransaction(async (transaction) => { + const emailAddressTransactionResponse = await transaction.run(userEmailCypher, { + notificationUserIds, + }) + return emailAddressTransactionResponse.records.map((record) => record.get('emailAddress')) + }) + try { + const emailAddresses = await writeTxResultPromise + return emailAddresses + } catch (error) { + throw new Error(error) + } finally { + session.close() + } +} + +const publishNotifications = async (context, promises) => { + let notifications = await Promise.all(promises) + notifications = notifications.flat() + const notificationsEmailAddresses = await queryNotificationEmails( + context, + notifications.map((notification) => notification.to.id), + ) + notifications.forEach((notificationAdded, index) => { + pubsub.publish(NOTIFICATION_ADDED, { notificationAdded }) + if (notificationAdded.to.sendNotificationEmails) { + // Wolle await + sendMail( + notificationTemplate({ + email: notificationsEmailAddresses[index].email, + notification: notificationAdded, + }), + ) + } + }) } const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const idsOfUsers = extractMentionedUsers(args.content) const post = await resolve(root, args, context, resolveInfo) if (post) { - await publishNotifications( + await publishNotifications(context, [ notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context), - ) + ]) } return post } @@ -26,10 +69,10 @@ const handleContentDataOfComment = async (resolve, root, args, context, resolveI const comment = await resolve(root, args, context, resolveInfo) const [postAuthor] = await postAuthorOfComment(comment.id, { context }) idsOfUsers = idsOfUsers.filter((id) => id !== postAuthor.id) - await publishNotifications( + await publishNotifications(context, [ notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context), notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context), - ) + ]) return comment } diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 6cfd22268..b8d024216 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -153,6 +153,10 @@ export default { type: 'boolean', default: false, }, + sendNotificationEmails: { + type: 'boolean', + default: true, + }, locale: { type: 'string', allow: [null], diff --git a/backend/src/schema/resolvers/registration.js b/backend/src/schema/resolvers/registration.js index 2796028fe..fc504f7cd 100644 --- a/backend/src/schema/resolvers/registration.js +++ b/backend/src/schema/resolvers/registration.js @@ -93,6 +93,7 @@ const signupCypher = (inviteCode) => { SET user.updatedAt = toString(datetime()) SET user.allowEmbedIframes = FALSE SET user.showShoutsPublicly = FALSE + SET user.sendNotificationEmails = TRUE SET email.verifiedAt = toString(datetime()) RETURN user {.*} ` diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index c6bb64125..5dc78c5e1 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -290,6 +290,7 @@ export default { 'termsAndConditionsAgreedAt', 'allowEmbedIframes', 'showShoutsPublicly', + 'sendNotificationEmails', 'locale', ], boolean: { diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index b8b805a02..772dedf6b 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -46,6 +46,7 @@ type User { allowEmbedIframes: Boolean showShoutsPublicly: Boolean + sendNotificationEmails: Boolean locale: String friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)") @@ -207,6 +208,7 @@ type Mutation { termsAndConditionsAgreedAt: String allowEmbedIframes: Boolean showShoutsPublicly: Boolean + sendNotificationEmails: Boolean locale: String ): User diff --git a/cypress/integration/User.SettingNotifications.feature b/cypress/integration/User.SettingNotifications.feature new file mode 100644 index 000000000..7e4301b81 --- /dev/null +++ b/cypress/integration/User.SettingNotifications.feature @@ -0,0 +1,20 @@ +Feature: User sets donations info settings + As a user + I want to change my notifications settings + In order to manage the notifications + + Background: + Given the following "users" are in the database: + | email | password | id | name | slug | termsAndConditionsAgreedVersion | + | peterpan@example.org | 123 | id-of-peter-pan | Peter Pan | peter-pan | 0.0.4 | + | user@example.org | 123 | user | User | user | 0.0.4 | + And I am logged in as "peter-pan" + + Scenario: The notifications setting "Send e-mail notifications" is set to true by default and can be set to false + # When I navigate to my "Notifications" settings page + When I navigate to page "/settings/notifications" + Then the checkbox with ID "send-email" should "be.checked" + And I click on element with ID "send-email" + And the checkbox with ID "send-email" should "not.be.checked" + Then I click save + And I see a toaster with "Notifications settings saved!" diff --git a/cypress/integration/User.SettingNotifications/I_click_on_element_with_ID_{string}.js b/cypress/integration/User.SettingNotifications/I_click_on_element_with_ID_{string}.js new file mode 100644 index 000000000..251c38941 --- /dev/null +++ b/cypress/integration/User.SettingNotifications/I_click_on_element_with_ID_{string}.js @@ -0,0 +1,5 @@ +import { When } from "cypress-cucumber-preprocessor/steps"; + +When("I click on element with ID {string}", (id) => { + cy.get('#' + id).click() +}) diff --git a/cypress/integration/User.SettingNotifications/I_click_save.js b/cypress/integration/User.SettingNotifications/I_click_save.js new file mode 100644 index 000000000..32d702f1e --- /dev/null +++ b/cypress/integration/User.SettingNotifications/I_click_save.js @@ -0,0 +1,5 @@ +import { Then } from "cypress-cucumber-preprocessor/steps"; + +Then("I click save", () => { + cy.get(".save-button").click() +}) \ No newline at end of file diff --git a/cypress/integration/User.SettingNotifications/I_click_the_checkbox_show_donations_progress_bar_and_save.js b/cypress/integration/User.SettingNotifications/I_click_the_checkbox_show_donations_progress_bar_and_save.js new file mode 100644 index 000000000..b4289dd5e --- /dev/null +++ b/cypress/integration/User.SettingNotifications/I_click_the_checkbox_show_donations_progress_bar_and_save.js @@ -0,0 +1,6 @@ +import { Then } from "cypress-cucumber-preprocessor/steps"; + +Then("I click the checkbox show donations progress bar and save", () => { + cy.get("#showDonations").click() + cy.get(".donations-info-button").click() +}) \ No newline at end of file diff --git a/cypress/integration/UserProfile.ChangePassword/I_submit_the_form copy.js b/cypress/integration/UserProfile.ChangePassword/I_submit_the_form.js similarity index 100% rename from cypress/integration/UserProfile.ChangePassword/I_submit_the_form copy.js rename to cypress/integration/UserProfile.ChangePassword/I_submit_the_form.js diff --git a/cypress/integration/UserProfile.ChangePassword/I_see_a_{string}_message.js b/cypress/integration/common/I_see_a_{string}_message.js similarity index 61% rename from cypress/integration/UserProfile.ChangePassword/I_see_a_{string}_message.js rename to cypress/integration/common/I_see_a_{string}_message.js index 90ddf0bd3..6cc2cbf6b 100644 --- a/cypress/integration/UserProfile.ChangePassword/I_see_a_{string}_message.js +++ b/cypress/integration/common/I_see_a_{string}_message.js @@ -1,5 +1,5 @@ import { Then } from "cypress-cucumber-preprocessor/steps"; -Then("I see a {string} message:", (type, message) => { +Then("I see a {string} message:", (_type, message) => { cy.contains(message); }); \ No newline at end of file diff --git a/cypress/integration/common/the_checkbox_with_ID_{string}_should_{string}.js b/cypress/integration/common/the_checkbox_with_ID_{string}_should_{string}.js new file mode 100644 index 000000000..ad3f7f3cc --- /dev/null +++ b/cypress/integration/common/the_checkbox_with_ID_{string}_should_{string}.js @@ -0,0 +1,5 @@ +import { When } from "cypress-cucumber-preprocessor/steps"; + +When("the checkbox with ID {string} should {string}", (id, value) => { + cy.get('#' + id).should(value) +}) diff --git a/webapp/components/Registration/RegistrationSlideEmail.vue b/webapp/components/Registration/RegistrationSlideEmail.vue index 5289248cc..045269f00 100644 --- a/webapp/components/Registration/RegistrationSlideEmail.vue +++ b/webapp/components/Registration/RegistrationSlideEmail.vue @@ -154,7 +154,7 @@ export default { this.sliderData.setSliderValuesCallback(null, { sliderSettings: { buttonLoading: true }, }) - const response = await this.$apollo.mutate({ mutation: SignupMutation, variables }) // e-mail is send in emailMiddleware of backend + const response = await this.$apollo.mutate({ mutation: SignupMutation, variables }) // e-mail is send in loginMiddleware of backend this.sliderData.setSliderValuesCallback(null, { sliderData: { request: { variables }, response: response.data }, }) diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 4b3a67775..b3f131238 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -41,6 +41,7 @@ export default (i18n) => { url } showShoutsPublicly + sendNotificationEmails } } ` @@ -224,6 +225,7 @@ export const updateUserMutation = () => { $about: String $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean + $sendNotificationEmails: Boolean $termsAndConditionsAgreedVersion: String $avatar: ImageInput ) { @@ -235,6 +237,7 @@ export const updateUserMutation = () => { about: $about allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly + sendNotificationEmails: $sendNotificationEmails termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion avatar: $avatar ) { @@ -245,6 +248,7 @@ export const updateUserMutation = () => { about allowEmbedIframes showShoutsPublicly + sendNotificationEmails locale termsAndConditionsAgreedVersion avatar { @@ -275,6 +279,7 @@ export const currentUserQuery = gql` locale allowEmbedIframes showShoutsPublicly + sendNotificationEmails termsAndConditionsAgreedVersion socialMedia { id diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 733a42629..9816e167a 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -739,13 +739,18 @@ "unmuted": "{name} ist nicht mehr stummgeschaltet" }, "name": "Einstellungen", + "notifications": { + "name": "Benachrichtigungen", + "send-email-notifications": "Sende E-Mail-Benachrichtigungen", + "success-update": "Benachrichtigungs-Einstellungen gespeichert!" + }, "organizations": { "name": "Meine Organisationen" }, "privacy": { "make-shouts-public": "Teile von mir empfohlene Artikel öffentlich auf meinem Profil", "name": "Privatsphäre", - "success-update": "Privatsphäre-Einstellungen gespeichert" + "success-update": "Privatsphäre-Einstellungen gespeichert!" }, "security": { "change-password": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 3c26cd93e..11b920056 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -739,13 +739,18 @@ "unmuted": "{name} is unmuted again" }, "name": "Settings", + "notifications": { + "name": "Notifications", + "send-email-notifications": "Send e-mail notifications", + "success-update": "Notifications settings saved!" + }, "organizations": { "name": "My Organizations" }, "privacy": { "make-shouts-public": "Share articles I have shouted on my public profile", "name": "Privacy", - "success-update": "Privacy settings saved" + "success-update": "Privacy settings saved!" }, "security": { "change-password": { diff --git a/webapp/pages/settings.vue b/webapp/pages/settings.vue index 6bd78b701..360f1e969 100644 --- a/webapp/pages/settings.vue +++ b/webapp/pages/settings.vue @@ -51,6 +51,10 @@ export default { name: this.$t('settings.embeds.name'), path: `/settings/embeds`, }, + { + name: this.$t('settings.notifications.name'), + path: '/settings/notifications', + }, { name: this.$t('settings.download.name'), path: `/settings/data-download`, diff --git a/webapp/pages/settings/notifications.spec.js b/webapp/pages/settings/notifications.spec.js new file mode 100644 index 000000000..7b43ef2c4 --- /dev/null +++ b/webapp/pages/settings/notifications.spec.js @@ -0,0 +1,70 @@ +import Vuex from 'vuex' +import { mount } from '@vue/test-utils' +import Notifications from './notifications.vue' + +const localVue = global.localVue + +describe('notifications.vue', () => { + let wrapper + let mocks + let store + + beforeEach(() => { + mocks = { + $t: jest.fn(), + $apollo: { + mutate: jest.fn(), + }, + $toast: { + success: jest.fn(), + error: jest.fn(), + }, + } + store = new Vuex.Store({ + getters: { + 'auth/user': () => { + return { + id: 'u343', + name: 'MyAccount', + sendNotificationEmails: true, + } + }, + }, + }) + }) + + describe('mount', () => { + const Wrapper = () => { + return mount(Notifications, { + store, + mocks, + localVue, + }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders', () => { + expect(wrapper.is('.base-card')).toBe(true) + }) + + it('clicking on submit changes notifyByEmail to false', async () => { + wrapper.find('#send-email').trigger('click') + await wrapper.vm.$nextTick() + wrapper.find('.base-button').trigger('click') + expect(wrapper.vm.notifyByEmail).toBe(false) + }) + + it('clicking on submit with a server error shows a toast and notifyByEmail is still true', async () => { + mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' }) + wrapper.find('#send-email').trigger('click') + await wrapper.vm.$nextTick() + await wrapper.find('.base-button').trigger('click') + await wrapper.vm.$nextTick() + expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!') + expect(wrapper.vm.notifyByEmail).toBe(true) + }) + }) +}) diff --git a/webapp/pages/settings/notifications.vue b/webapp/pages/settings/notifications.vue new file mode 100644 index 000000000..a2828a1a9 --- /dev/null +++ b/webapp/pages/settings/notifications.vue @@ -0,0 +1,63 @@ + + +