mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
feat(backend): chat notify via email (#8314)
* client * backend * tests * also save awaySince timestamp * remove console.log * chat notification logic * send notification mails for chat messages * externalize online check, resolver resover first * prevent email notifications for blocked users comment * respect user email notification settings * properly handle null case for email destructuring * tests * corrected mail style --------- Co-authored-by: mahula <lenzmath@posteo.de>
This commit is contained in:
parent
4e827de29d
commit
2eaaa7af39
@ -6,6 +6,7 @@ import {
|
||||
resetPasswordTemplate,
|
||||
wrongAccountTemplate,
|
||||
notificationTemplate,
|
||||
chatMessageTemplate,
|
||||
} from './templateBuilder'
|
||||
|
||||
const englishHint = 'English version below!'
|
||||
@ -34,6 +35,12 @@ const resetPasswordTemplateData = () => ({
|
||||
name: 'Mr Example',
|
||||
},
|
||||
})
|
||||
const chatMessageTemplateData = {
|
||||
email: 'test@example.org',
|
||||
variables: {
|
||||
name: 'Mr Example',
|
||||
},
|
||||
}
|
||||
const wrongAccountTemplateData = () => ({
|
||||
email: 'test@example.org',
|
||||
variables: {},
|
||||
@ -163,6 +170,31 @@ describe('templateBuilder', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatMessageTemplate', () => {
|
||||
describe('multi language', () => {
|
||||
it('e-mail is build with all data', () => {
|
||||
const subject = 'Neue Chatnachricht | New chat message'
|
||||
const actionUrl = new URL('/chat', CONFIG.CLIENT_URI).toString()
|
||||
const enContent = 'You have received a new chat message.'
|
||||
const deContent = 'Du hast eine neue Chatnachricht erhalten.'
|
||||
testEmailData(null, chatMessageTemplate, chatMessageTemplateData, [
|
||||
...textsStandard,
|
||||
{
|
||||
templPropName: 'subject',
|
||||
isContaining: false,
|
||||
text: subject,
|
||||
},
|
||||
englishHint,
|
||||
actionUrl,
|
||||
chatMessageTemplateData.variables.name,
|
||||
enContent,
|
||||
deContent,
|
||||
supportUrl,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrongAccountTemplate', () => {
|
||||
describe('multi language', () => {
|
||||
it('e-mail is build with all data', () => {
|
||||
|
||||
@ -71,6 +71,19 @@ export const resetPasswordTemplate = ({ email, variables: { nonce, name } }) =>
|
||||
}
|
||||
}
|
||||
|
||||
export const chatMessageTemplate = ({ email, variables: { name } }) => {
|
||||
const subject = 'Neue Chatnachricht | New chat message'
|
||||
const actionUrl = new URL('/chat', CONFIG.CLIENT_URI)
|
||||
const renderParams = { ...defaultParams, englishHint, actionUrl, name, subject }
|
||||
|
||||
return {
|
||||
from,
|
||||
to: email,
|
||||
subject,
|
||||
html: mustache.render(templates.layout, renderParams, { content: templates.chatMessage }),
|
||||
}
|
||||
}
|
||||
|
||||
export const wrongAccountTemplate = ({ email, _variables = {} }) => {
|
||||
const subject = 'Falsche Mailadresse? | Wrong E-mail?'
|
||||
const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI)
|
||||
|
||||
105
backend/src/middleware/helpers/email/templates/chatMessage.html
Normal file
105
backend/src/middleware/helpers/email/templates/chatMessage.html
Normal file
@ -0,0 +1,105 @@
|
||||
<!-- Email Body German : BEGIN -->
|
||||
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
|
||||
style="margin: auto;">
|
||||
|
||||
<!-- Hero Image, Flush : BEGIN -->
|
||||
<tr>
|
||||
<td style="background-color: #ffffff;">
|
||||
<img
|
||||
src="{{{ welcomeImageUrl }}}"
|
||||
width="300" height="" alt="Welcome image" border="0"
|
||||
style="width: 100%; max-width: 300px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block; padding: 20px;"
|
||||
class="g-img">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Hero Image, Flush : END -->
|
||||
|
||||
<!-- 1 Column Text + Button : BEGIN -->
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; padding: 20px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td
|
||||
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
|
||||
<h1
|
||||
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
|
||||
Hallo {{ name }}!</h1>
|
||||
<p style="margin: 0;">Du hast eine neue Chatnachricht erhalten.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 20px;">
|
||||
<!-- Button : BEGIN -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
|
||||
<tr>
|
||||
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
|
||||
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
|
||||
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Chat anzeigen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Button : END -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 1 Column Text + Button : END -->
|
||||
|
||||
</table>
|
||||
<!-- Email Body German : END -->
|
||||
|
||||
<!-- Email Body English : BEGIN -->
|
||||
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
|
||||
style="margin: auto;">
|
||||
<tr>
|
||||
<td style="padding: 20px 0; text-align: center">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Hero Image, Flush : BEGIN -->
|
||||
<tr>
|
||||
<td style="background-color: #ffffff;">
|
||||
<img
|
||||
src="{{{ welcomeImageUrl }}}"
|
||||
width="300" height="" alt="Welcome image" border="0"
|
||||
style="width: 100%; max-width: 300px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block; padding: 20px;"
|
||||
class="g-img">
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Hero Image, Flush : END -->
|
||||
|
||||
<!-- 1 Column Text + Button : BEGIN -->
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; padding: 20px;">
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td
|
||||
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
|
||||
<h1
|
||||
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
|
||||
Hello {{ name }}!</h1>
|
||||
<p style="margin: 0;">You have received a new chat message.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 20px;">
|
||||
<!-- Button : BEGIN -->
|
||||
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
|
||||
<tr>
|
||||
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
|
||||
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
|
||||
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Show Chat</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Button : END -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 1 Column Text + Button : END -->
|
||||
|
||||
</table>
|
||||
<!-- Email Body English : END -->
|
||||
@ -7,5 +7,6 @@ export const signup = readFile('./signup.html')
|
||||
export const passwordReset = readFile('./resetPassword.html')
|
||||
export const wrongAccount = readFile('./wrongAccount.html')
|
||||
export const emailVerification = readFile('./emailVerification.html')
|
||||
export const chatMessage = readFile('./chatMessage.html')
|
||||
|
||||
export const layout = readFile('./layout.html')
|
||||
|
||||
46
backend/src/middleware/helpers/isUserOnline.spec.ts
Normal file
46
backend/src/middleware/helpers/isUserOnline.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { isUserOnline } from './isUserOnline'
|
||||
|
||||
let user
|
||||
|
||||
describe('isUserOnline', () => {
|
||||
beforeEach(() => {
|
||||
user = {
|
||||
properties: {
|
||||
lastActiveAt: null,
|
||||
awaySince: null,
|
||||
lastOnlineStatus: null,
|
||||
},
|
||||
}
|
||||
})
|
||||
describe('user has lastOnlineStatus `online`', () => {
|
||||
it('returns true if he was active within the last 90 seconds', () => {
|
||||
user.properties.lastOnlineStatus = 'online'
|
||||
user.properties.lastActiveAt = new Date()
|
||||
expect(isUserOnline(user)).toBe(true)
|
||||
})
|
||||
it('returns false if he was not active within the last 90 seconds', () => {
|
||||
user.properties.lastOnlineStatus = 'online'
|
||||
user.properties.lastActiveAt = new Date().getTime() - 90001
|
||||
expect(isUserOnline(user)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has lastOnlineStatus `away`', () => {
|
||||
it('returns true if he went away less then 180 seconds ago', () => {
|
||||
user.properties.lastOnlineStatus = 'away'
|
||||
user.properties.awaySince = new Date()
|
||||
expect(isUserOnline(user)).toBe(true)
|
||||
})
|
||||
it('returns false if he went away more then 180 seconds ago', () => {
|
||||
user.properties.lastOnlineStatus = 'away'
|
||||
user.properties.awaySince = new Date().getTime() - 180001
|
||||
expect(isUserOnline(user)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user is freshly created and has never logged in', () => {
|
||||
it('returns false', () => {
|
||||
expect(isUserOnline(user)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
16
backend/src/middleware/helpers/isUserOnline.ts
Normal file
16
backend/src/middleware/helpers/isUserOnline.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export const isUserOnline = (user) => {
|
||||
// Is Recipient considered online
|
||||
const lastActive = new Date(user.properties.lastActiveAt).getTime()
|
||||
const awaySince = new Date(user.properties.awaySince).getTime()
|
||||
const now = new Date().getTime()
|
||||
const status = user.properties.lastOnlineStatus
|
||||
if (
|
||||
// online & last active less than 1.5min -> online
|
||||
(status === 'online' && now - lastActive < 90000) ||
|
||||
// away for less then 3min -> online
|
||||
(status === 'away' && now - awaySince < 180000)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import gql from 'graphql-tag'
|
||||
import { cleanDatabase } from '../../db/factories'
|
||||
import Factory, { cleanDatabase } from '../../db/factories'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import { getNeode, getDriver } from '../../db/neo4j'
|
||||
import createServer, { pubsub } from '../../server'
|
||||
@ -10,6 +10,23 @@ import {
|
||||
changeGroupMemberRoleMutation,
|
||||
removeUserFromGroupMutation,
|
||||
} from '../../graphql/groups'
|
||||
import { createMessageMutation } from '../../graphql/messages'
|
||||
import { createRoomMutation } from '../../graphql/rooms'
|
||||
|
||||
const sendMailMock = jest.fn()
|
||||
jest.mock('../helpers/email/sendMail', () => ({
|
||||
sendMail: () => sendMailMock(),
|
||||
}))
|
||||
|
||||
const chatMessageTemplateMock = jest.fn()
|
||||
jest.mock('../helpers/email/templateBuilder', () => ({
|
||||
chatMessageTemplate: () => chatMessageTemplateMock(),
|
||||
}))
|
||||
|
||||
let isUserOnlineMock = jest.fn()
|
||||
jest.mock('../helpers/isUserOnline', () => ({
|
||||
isUserOnline: () => isUserOnlineMock(),
|
||||
}))
|
||||
|
||||
let server, query, mutate, notifiedUser, authenticatedUser
|
||||
let publishSpy
|
||||
@ -633,6 +650,115 @@ describe('notifications', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat email notifications', () => {
|
||||
let chatSender
|
||||
let chatReceiver
|
||||
let roomId
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
chatSender = await neode.create(
|
||||
'User',
|
||||
{
|
||||
id: 'chatSender',
|
||||
name: 'chatSender',
|
||||
slug: 'chatSender',
|
||||
},
|
||||
{
|
||||
email: 'chatSender@example.org',
|
||||
password: '1234',
|
||||
},
|
||||
)
|
||||
|
||||
chatReceiver = await Factory.build(
|
||||
'user',
|
||||
{ id: 'chatReceiver', name: 'chatReceiver', slug: 'chatReceiver' },
|
||||
{ email: 'user@example.org' },
|
||||
)
|
||||
|
||||
authenticatedUser = await chatSender.toJson()
|
||||
|
||||
const room = await mutate({
|
||||
mutation: createRoomMutation(),
|
||||
variables: {
|
||||
userId: 'chatReceiver',
|
||||
},
|
||||
})
|
||||
roomId = room.data.CreateRoom.id
|
||||
})
|
||||
|
||||
describe('chatReceiver is online', () => {
|
||||
it('sends no email', async () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(true)
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled()
|
||||
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatReceiver is offline', () => {
|
||||
it('sends an email', async () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(false)
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sendMailMock).toHaveBeenCalledTimes(1)
|
||||
expect(chatMessageTemplateMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatReceiver has blocked chatSender', () => {
|
||||
it('sends no email', async () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(false)
|
||||
await chatReceiver.relateTo(chatSender, 'blocked')
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled()
|
||||
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('chatReceiver has disabled email notifications', () => {
|
||||
it('sends no email', async () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(false)
|
||||
await chatReceiver.update({ sendNotificationEmails: false })
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sendMailMock).not.toHaveBeenCalled()
|
||||
expect(chatMessageTemplateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('group notifications', () => {
|
||||
let groupOwner
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@ import { pubsub, NOTIFICATION_ADDED } from '../../server'
|
||||
import extractMentionedUsers from './mentions/extractMentionedUsers'
|
||||
import { validateNotifyUsers } from '../validation/validationMiddleware'
|
||||
import { sendMail } from '../helpers/email/sendMail'
|
||||
import { notificationTemplate } from '../helpers/email/templateBuilder'
|
||||
import { chatMessageTemplate, notificationTemplate } from '../helpers/email/templateBuilder'
|
||||
import { isUserOnline } from '../helpers/isUserOnline'
|
||||
|
||||
const queryNotificationEmails = async (context, notificationUserIds) => {
|
||||
if (!(notificationUserIds && notificationUserIds.length)) return []
|
||||
@ -314,6 +315,56 @@ const notifyUsersOfComment = async (label, commentId, reason, context) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateMessage = async (resolve, root, args, context, resolveInfo) => {
|
||||
// Execute resolver
|
||||
const result = await resolve(root, args, context, resolveInfo)
|
||||
|
||||
// Query Parameters
|
||||
const { roomId } = args
|
||||
const {
|
||||
user: { id: currentUserId },
|
||||
} = context
|
||||
|
||||
// Find Recipient
|
||||
const session = context.driver.session()
|
||||
const messageRecipient = session.readTransaction(async (transaction) => {
|
||||
const messageRecipientCypher = `
|
||||
MATCH (currentUser:User { id: $currentUserId })-[:CHATS_IN]->(room:Room { id: $roomId })
|
||||
MATCH (room)<-[:CHATS_IN]-(recipientUser:User)-[:PRIMARY_EMAIL]->(emailAddress:EmailAddress)
|
||||
WHERE NOT recipientUser.id = $currentUserId
|
||||
AND NOT (recipientUser)-[:BLOCKED]-(currentUser)
|
||||
AND recipientUser.sendNotificationEmails = true
|
||||
RETURN recipientUser, emailAddress {.email}
|
||||
`
|
||||
const txResponse = await transaction.run(messageRecipientCypher, {
|
||||
currentUserId,
|
||||
roomId,
|
||||
})
|
||||
|
||||
return {
|
||||
user: await txResponse.records.map((record) => record.get('recipientUser'))[0],
|
||||
email: await txResponse.records.map((record) => record.get('emailAddress'))[0]?.email,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
// Execute Query
|
||||
const { user, email } = await messageRecipient
|
||||
|
||||
// Send EMail if we found a user(not blocked) and he is not considered online
|
||||
if (user && !isUserOnline(user)) {
|
||||
void sendMail(chatMessageTemplate({ email, variables: { name: user.properties.name } }))
|
||||
}
|
||||
|
||||
// Return resolver result to client
|
||||
return result
|
||||
} catch (error) {
|
||||
throw new Error(error)
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
CreatePost: handleContentDataOfPost,
|
||||
@ -324,5 +375,6 @@ export default {
|
||||
LeaveGroup: handleLeaveGroup,
|
||||
ChangeGroupMemberRole: handleChangeGroupMemberRole,
|
||||
RemoveUserFromGroup: handleRemoveUserFromGroup,
|
||||
CreateMessage: handleCreateMessage,
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user