diff --git a/backend/src/db/factories.ts b/backend/src/db/factories.ts index c75c92fdd..c787fd1ab 100644 --- a/backend/src/db/factories.ts +++ b/backend/src/db/factories.ts @@ -70,7 +70,13 @@ Factory.define('basicUser') termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', allowEmbedIframes: false, showShoutsPublicly: false, - sendNotificationEmails: true, + emailNotificationsCommentOnObservedPost: true, + emailNotificationsPostByFollowedUser: true, + emailNotificationsPostInGroup: true, + emailNotificationsGroupMemberJoined: true, + emailNotificationsGroupMemberLeft: true, + emailNotificationsGroupMemberRemoved: true, + emailNotificationsGroupMemberRoleChanged: true, locale: 'en', }) .attr('slug', ['slug', 'name'], (slug, name) => { diff --git a/backend/src/db/migrate/store.ts b/backend/src/db/migrate/store.ts index 0c0b63943..ca1525f0c 100644 --- a/backend/src/db/migrate/store.ts +++ b/backend/src/db/migrate/store.ts @@ -53,19 +53,25 @@ const createDefaultAdminUser = async (session) => { `MERGE (e:EmailAddress { email: "${defaultAdmin.email}", createdAt: toString(datetime()) - })-[:BELONGS_TO]->(u:User { - name: "${defaultAdmin.name}", - encryptedPassword: "${defaultAdmin.password}", - role: "admin", - id: "${defaultAdmin.id}", - slug: "${defaultAdmin.slug}", - createdAt: toString(datetime()), - allowEmbedIframes: false, - showShoutsPublicly: false, - sendNotificationEmails: true, - deleted: false, - disabled: false - })-[:PRIMARY_EMAIL]->(e)`, + })-[:BELONGS_TO]->(u:User { + name: "${defaultAdmin.name}", + encryptedPassword: "${defaultAdmin.password}", + role: "admin", + id: "${defaultAdmin.id}", + slug: "${defaultAdmin.slug}", + createdAt: toString(datetime()), + allowEmbedIframes: false, + showShoutsPublicly: false, + emailNotificationsCommentOnObservedPost: true + emailNotificationsPostByFollowedUser: true + emailNotificationsPostInGroup: true + emailNotificationsGroupMemberJoined: true + emailNotificationsGroupMemberLeft: true + emailNotificationsGroupMemberRemoved: true + emailNotificationsGroupMemberRoleChanged: true + deleted: false, + disabled: false + })-[:PRIMARY_EMAIL]->(e)`, ) }) try { diff --git a/backend/src/db/migrations/20250405030454-emailnotificationsettings.ts b/backend/src/db/migrations/20250405030454-emailnotificationsettings.ts new file mode 100644 index 000000000..ee78593ec --- /dev/null +++ b/backend/src/db/migrations/20250405030454-emailnotificationsettings.ts @@ -0,0 +1,67 @@ +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.emailNotificationsCommentOnObservedPost = user.sendNotificationEmails + SET user.emailNotificationsPostByFollowedUser = user.sendNotificationEmails + SET user.emailNotificationsPostInGroup = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberJoined = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberLeft = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberRemoved = user.sendNotificationEmails + SET user.emailNotificationsGroupMemberRoleChanged = user.sendNotificationEmails + REMOVE user.sendNotificationEmails + `) + 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) + SET user.sendNotificationEmails = true + REMOVE user.emailNotificationsCommentOnObservedPost + REMOVE user.emailNotificationsPostByFollowedUser + REMOVE user.emailNotificationsPostInGroup + REMOVE user.emailNotificationsGroupMemberJoined + REMOVE user.emailNotificationsGroupMemberLeft + REMOVE user.emailNotificationsGroupMemberRemoved + REMOVE user.emailNotificationsGroupMemberRoleChanged + `) + 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/notifications/notificationsMiddleware.ts b/backend/src/middleware/notifications/notificationsMiddleware.ts index aa2cee06e..824729615 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.ts +++ b/backend/src/middleware/notifications/notificationsMiddleware.ts @@ -31,7 +31,7 @@ const queryNotificationEmails = async (context, notificationUserIds) => { } } -const publishNotifications = async (context, promises) => { +const publishNotifications = async (context, promises, emailNotificationSetting: string) => { let notifications = await Promise.all(promises) notifications = notifications.flat() const notificationsEmailAddresses = await queryNotificationEmails( @@ -40,7 +40,7 @@ const publishNotifications = async (context, promises) => { ) notifications.forEach((notificationAdded, index) => { pubsub.publish(NOTIFICATION_ADDED, { notificationAdded }) - if (notificationAdded.to.sendNotificationEmails) { + if (notificationAdded.to[emailNotificationSetting] ?? true) { // Default to true sendMail( notificationTemplate({ email: notificationsEmailAddresses[index].email, @@ -57,7 +57,7 @@ const handleJoinGroup = async (resolve, root, args, context, resolveInfo) => { if (user) { await publishNotifications(context, [ notifyOwnersOfGroup(groupId, userId, 'user_joined_group', context), - ]) + ], 'emailNotificationsGroupMemberJoined') } return user } @@ -68,7 +68,7 @@ const handleLeaveGroup = async (resolve, root, args, context, resolveInfo) => { if (user) { await publishNotifications(context, [ notifyOwnersOfGroup(groupId, userId, 'user_left_group', context), - ]) + ], 'emailNotificationsGroupMemberLeft') } return user } @@ -79,7 +79,7 @@ const handleChangeGroupMemberRole = async (resolve, root, args, context, resolve if (user) { await publishNotifications(context, [ notifyMemberOfGroup(groupId, userId, 'changed_group_member_role', context), - ]) + ], 'emailNotificationsGroupMemberRoleChanged') } return user } @@ -90,7 +90,7 @@ const handleRemoveUserFromGroup = async (resolve, root, args, context, resolveIn if (user) { await publishNotifications(context, [ notifyMemberOfGroup(groupId, userId, 'removed_user_from_group', context), - ]) + ], 'emailNotificationsGroupMemberRemoved') } return user } @@ -101,7 +101,7 @@ const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo if (post) { await publishNotifications(context, [ notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context), - ]) + ], 'TODO') } return post } @@ -120,11 +120,20 @@ const handleContentDataOfComment = async (resolve, root, args, context, resolveI 'mentioned_in_comment', context, ), + ], 'TODO') + + await publishNotifications(context, [ notifyUsersOfComment('Comment', comment.id, 'commented_on_post', context), - ]) + ], 'emailNotificationsCommentOnObservedPost') + return comment } +/* TODO unused +emailNotificationsPostByFollowedUser: true +emailNotificationsPostInGroup: true +*/ + const postAuthorOfComment = async (commentId, { context }) => { const session = context.driver.session() let postAuthorId diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index 9b828e27e..df0699a13 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -155,10 +155,37 @@ export default { type: 'boolean', default: false, }, - sendNotificationEmails: { + + // emailNotifications + emailNotificationsCommentOnObservedPost: { type: 'boolean', default: true, }, + emailNotificationsPostByFollowedUser: { + type: 'boolean', + default: true, + }, + emailNotificationsPostInGroup: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberJoined: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberLeft: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberRemoved: { + type: 'boolean', + default: true, + }, + emailNotificationsGroupMemberRoleChanged: { + type: 'boolean', + default: true, + }, + locale: { type: 'string', allow: [null], diff --git a/backend/src/schema/resolvers/registration.ts b/backend/src/schema/resolvers/registration.ts index 8d5aac346..de23a91e5 100644 --- a/backend/src/schema/resolvers/registration.ts +++ b/backend/src/schema/resolvers/registration.ts @@ -98,7 +98,13 @@ const signupCypher = (inviteCode) => { SET user.updatedAt = toString(datetime()) SET user.allowEmbedIframes = false SET user.showShoutsPublicly = false - SET user.sendNotificationEmails = true + SET user.emailNotificationsCommentOnObservedPost = true + SET user.emailNotificationsPostByFollowedUser = true + SET user.emailNotificationsPostInGroup = true + SET user.emailNotificationsGroupMemberJoined = true + SET user.emailNotificationsGroupMemberLeft = true + SET user.emailNotificationsGroupMemberRemoved = true + SET user.emailNotificationsGroupMemberRoleChanged = true SET email.verifiedAt = toString(datetime()) WITH user OPTIONAL MATCH (post:Post)-[:IN]->(group:Group) diff --git a/backend/src/schema/resolvers/users.ts b/backend/src/schema/resolvers/users.ts index cab0bc8a3..e905dd0a4 100644 --- a/backend/src/schema/resolvers/users.ts +++ b/backend/src/schema/resolvers/users.ts @@ -150,6 +150,30 @@ export default { } params.termsAndConditionsAgreedAt = new Date().toISOString() } + + const { + emailNotificationSettings, + }: { emailNotificationSettings: { name: string; value: boolean }[] | undefined } = params + if (emailNotificationSettings) { + emailNotificationSettings.forEach((setting) => { + const allowedSettingNames = [ + 'commentOnObservedPost', + 'postByFollowedUser', + 'postInGroup', + 'groupMemberJoined', + 'groupMemberLeft', + 'groupMemberRemoved', + 'groupMemberRoleChanged', + ] + if (!allowedSettingNames.includes(setting.name)) { + return + } + params[ + 'emailNotifications' + setting.name.charAt(0).toUpperCase() + setting.name.slice(1) + ] = setting.value + }) + } + const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { @@ -355,6 +379,44 @@ export default { const [{ email }] = result.records.map((r) => r.get('e').properties) return email }, + emailNotificationSettings: async (parent, params, context, resolveInfo) => { + const { user } = context + const { id } = parent + // Its not the own user + if (user.id !== id) { + return [] + } + + return [ + { + name: 'commentOnObservedPost', + type: 'post', + value: user.emailNotificationsCommentOnObservedPost ?? true, + }, + { + name: 'postByFollowedUser', + type: 'post', + value: user.emailNotificationsPostByFollowedUser ?? true, + }, + { name: 'postInGroup', type: 'post', value: user.emailNotificationsPostInGroup ?? true}, + { + name: 'groupMemberJoined', + type: 'group', + value: user.emailNotificationsGroupMemberJoined ?? true, + }, + { name: 'groupMemberLeft', type: 'group', value: user.emailNotificationsGroupMemberLeft ?? true}, + { + name: 'groupMemberRemoved', + type: 'group', + value: user.emailNotificationsGroupMemberRemoved ?? true, + }, + { + name: 'groupMemberRoleChanged', + type: 'group', + value: user.emailNotificationsGroupMemberRoleChanged ?? true, + }, + ] + }, ...Resolver('User', { undefinedToNull: [ 'actorId', @@ -366,7 +428,6 @@ 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 70b10aa42..bd1bea27a 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -19,6 +19,17 @@ enum _UserOrdering { locale_desc } +input EmailNotificationSettingsInput { + name: String + value: Boolean +} + +type EmailNotificationSettings { + name: String + type: String + value: Boolean +} + type User { id: ID! actorId: String @@ -46,7 +57,7 @@ type User { allowEmbedIframes: Boolean showShoutsPublicly: Boolean - sendNotificationEmails: Boolean + emailNotificationSettings: [EmailNotificationSettings]! locale: String friends: [User]! @relation(name: "FRIENDS", direction: "BOTH") friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)") @@ -206,7 +217,7 @@ type Mutation { termsAndConditionsAgreedAt: String allowEmbedIframes: Boolean showShoutsPublicly: Boolean - sendNotificationEmails: Boolean + emailNotificationSettings: [EmailNotificationSettingsInput] locale: String ): User diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 4b743a0e3..692983b7f 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -46,7 +46,11 @@ export const profileUserQuery = (i18n) => { url } showShoutsPublicly - sendNotificationEmails + emailNotificationSettings { + name + type + value + } } } ` @@ -335,7 +339,7 @@ export const updateUserMutation = () => { $about: String $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean - $sendNotificationEmails: Boolean + $emailNotificationSettings: [EmailNotificationSettingsInput] $termsAndConditionsAgreedVersion: String $avatar: ImageInput $locationName: String # empty string '' sets it to null @@ -347,7 +351,7 @@ export const updateUserMutation = () => { about: $about allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly - sendNotificationEmails: $sendNotificationEmails + emailNotificationSettings: $emailNotificationSettings termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion avatar: $avatar locationName: $locationName @@ -359,7 +363,11 @@ export const updateUserMutation = () => { about allowEmbedIframes showShoutsPublicly - sendNotificationEmails + emailNotificationSettings { + name + type + value + } locale termsAndConditionsAgreedVersion avatar { @@ -390,7 +398,11 @@ export const currentUserQuery = gql` locale allowEmbedIframes showShoutsPublicly - sendNotificationEmails + emailNotificationSettings { + name + type + value + } termsAndConditionsAgreedVersion socialMedia { id