Skip to content

Commit

Permalink
feat: refactor send message to a cloud function (#3648)
Browse files Browse the repository at this point in the history
  • Loading branch information
mariojsnunes authored Jun 13, 2024
1 parent b251a4f commit 8fa50e0
Show file tree
Hide file tree
Showing 15 changed files with 186 additions and 212 deletions.
18 changes: 18 additions & 0 deletions functions/src/Utils/doc.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const createDoc = (data: object) => {
return {
...data,
_created: new Date().toISOString(),
_modified: new Date().toISOString(),
_deleted: false,
_id: _generateDocID(),
}
}

const _generateDocID = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let autoId = ''
for (let i = 0; i < 20; i++) {
autoId += chars.charAt(Math.floor(Math.random() * chars.length))
}
return autoId
}
2 changes: 2 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Admin from './admin'
import * as UserUpdates from './userUpdates'
import * as DiscussionUpdates from './discussionUpdates'
import * as QuestionUpdates from './questionUpdates'
import * as Messages from './messages/messages'

// the following endpoints are exposed for use by various triggers
// see individual files for more information
Expand All @@ -29,6 +30,7 @@ exports.adminGetUserEmail = Admin.getUserEmail

exports.seo = require('./seo')

exports.sendMessage = Messages.sendMessage
exports.emailNotifications = require('./emailNotifications')

// Only export development api when working locally (with functions emulator)
Expand Down
43 changes: 43 additions & 0 deletions functions/src/messages/messages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { handleSendMessage } from './messages' // Path to your cloud function file
import { SendMessage } from 'oa-shared'
import * as functions from 'firebase-functions'

const defaultData: SendMessage = {
to: 'to@email.com',
message: 'test message',
name: 'test user',
}

describe('sendMessage', () => {
it("should return a 401 if auth isn't provided", async () => {
expect(handleSendMessage(defaultData, {} as any)).rejects.toThrow(
new functions.https.HttpsError('unauthenticated', 'Unauthenticated'),
)
})

it('should return a 403 if the user is blocked', async () => {
const context = { auth: { uid: 'abc' } }

jest.mock('./messages', () => ({
isBlocked: () => jest.fn().mockResolvedValue(true),
reachedLimit: () => jest.fn().mockResolvedValue(false),
}))

expect(handleSendMessage(defaultData, context as any)).rejects.toThrow(
new functions.https.HttpsError('permission-denied', 'User is Blocked'),
)
})

it('should return a 429 if the user message limit exceeded', async () => {
const context = { auth: { uid: 'abc' } }

jest.mock('./messages', () => ({
isBlocked: () => jest.fn().mockResolvedValue(false),
reachedLimit: () => jest.fn().mockResolvedValue(true),
}))

expect(handleSendMessage(defaultData, context as any)).rejects.toThrow(
new functions.https.HttpsError('resource-exhausted', 'Limit exceeded'),
)
})
})
70 changes: 70 additions & 0 deletions functions/src/messages/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as functions from 'firebase-functions'
import { DB_ENDPOINTS } from '../models'
import { firebaseAdmin } from '../Firebase/admin'
import { createDoc } from '../Utils/doc.utils'

import type { SendMessage } from 'oa-shared'

const EMAIL_ADDRESS_SEND_LIMIT = 100

const isBlocked = async (uid: string) => {
const userReq = await firebaseAdmin
.firestore()
.collection(DB_ENDPOINTS.users)
.where('_authID', '==', uid)
.get()

const user = userReq.docs[0].data()

return !!user.isBlockedFromMessaging
}

const reachedLimit = async (email: string) => {
const userReq = await firebaseAdmin
.firestore()
.collection(DB_ENDPOINTS.messages)
.where('email', '==', email)
.count()
.get()

const count = userReq.data().count

return count >= EMAIL_ADDRESS_SEND_LIMIT
}

export const handleSendMessage = async (
data: SendMessage,
context: functions.https.CallableContext,
) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Unauthenticated')
}

if (await isBlocked(context.auth.uid)) {
throw new functions.https.HttpsError('permission-denied', 'User is Blocked')
}

if (await reachedLimit(context.auth.token.email)) {
throw new functions.https.HttpsError('resource-exhausted', 'Limit exceeded')
}

const newMessage = createDoc({
isSent: false,
toUserName: data.to,
email: context.auth.token.email,
message: data.message,
text: data.message,
})

await firebaseAdmin
.firestore()
.collection(DB_ENDPOINTS.messages)
.doc()
.set(newMessage)
}

export const sendMessage = functions
.runWith({
memory: '512MB',
})
.https.onCall(handleSendMessage)
4 changes: 4 additions & 0 deletions packages/cypress/src/integration/profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const userProfiletype = MOCK_DATA.users.settings_workplace_new
describe('[Profile]', () => {
beforeEach(() => {
cy.visit('/')
cy.intercept('POST', '/sendMessage', {
body: { result: null },
statusCode: 200,
}).as('sendMessage')
})

describe('[By Anonymous]', () => {
Expand Down
1 change: 1 addition & 0 deletions shared/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './maps'
export * from './notifications'
export * from './research'
export * from './user'
export * from './messages'
5 changes: 5 additions & 0 deletions shared/models/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type SendMessage = {
to: string
message: string
name: string
}
38 changes: 0 additions & 38 deletions src/pages/User/contact/UserContactFieldEmail.tsx

This file was deleted.

20 changes: 15 additions & 5 deletions src/pages/User/contact/UserContactForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ import { useCommonStores } from 'src/common/hooks/useCommonStores'
import { FactoryUser } from 'src/test/factories/User'
import { describe, expect, it, vi } from 'vitest'

import { contact } from '../labels'
import { UserContactForm } from './UserContactForm'

vi.mock('src/common/hooks/useCommonStores', () => {
return {
useCommonStores: () => ({
stores: {
messageStore: {
upload: () => vi.fn(),
},
userStore: {
getUserEmail: () => vi.fn().mockReturnValue('Bob@email.com'),
activeUser: () => vi.fn().mockReturnValue(true),
Expand All @@ -23,6 +21,17 @@ vi.mock('src/common/hooks/useCommonStores', () => {
}
})

vi.mock('src/services/message.service', () => {
return {
messageService: {
sendMessage: () =>
vi.fn().mockImplementation(() => {
return Promise.resolve()
}),
},
}
})

describe('UserContactForm', () => {
const profileUser = FactoryUser({ isContactableByPublic: true })

Expand All @@ -43,8 +52,9 @@ describe('UserContactForm', () => {
'I need to learn about plastics',
)

await user.click(screen.getByTestId('contact-submit'))
await screen.findByText('All sent')
const submitButton = screen.getByTestId('contact-submit')
await user.click(submitButton)
await screen.findByText(contact.successMessage)
})

it('renders nothing if not profile is not contactable', async () => {
Expand Down
40 changes: 13 additions & 27 deletions src/pages/User/contact/UserContactForm.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Form } from 'react-final-form'
import { observer } from 'mobx-react'
import { Button } from 'oa-components'
import { useCommonStores } from 'src/common/hooks/useCommonStores'
import {
UserContactError,
UserContactFieldEmail,
UserContactFieldMessage,
UserContactFieldName,
UserContactNotLoggedIn,
} from 'src/pages/User/contact'
import { contact } from 'src/pages/User/labels'
import { messageService } from 'src/services/message.service'
import { isUserContactable } from 'src/utils/helpers'
import { Box, Flex, Heading } from 'theme-ui'

Expand All @@ -25,42 +25,29 @@ type SubmitResults = { type: 'success' | 'error'; message: string }
export const UserContactForm = observer(({ user }: Props) => {
if (!isUserContactable(user)) return null

const { stores } = useCommonStores()
const { userStore } = stores
const { userStore } = useCommonStores().stores

if (!userStore.activeUser)
return <UserContactNotLoggedIn userName={user.userName} />

const [submitResults, setSubmitResults] = useState<SubmitResults | null>(null)
const [email, setEmail] = useState<string | null>(null)

useEffect(() => {
const fetchUserEmail = async () => {
return await userStore.getUserEmail()
}
fetchUserEmail().then((newEmail) => setEmail(newEmail))
}, [email])

const { button, title, successMessage } = contact
const buttonName = 'contact-submit'
const formId = 'contact-form'

const onSubmit = async (formValues, form) => {
setSubmitResults(null)
const values = {
toUserName: user.userName,
text: formValues.message,
...formValues,
}

if (email) {
try {
await stores.messageStore.upload(values)
setSubmitResults({ type: 'success', message: successMessage })
form.restart()
} catch (error) {
setSubmitResults({ type: 'error', message: error.message })
}
try {
await messageService.sendMessage({
to: user.userName,
message: formValues.message,
name: formValues.name,
})
setSubmitResults({ type: 'success', message: successMessage })
form.restart()
} catch (error) {
setSubmitResults({ type: 'error', message: error.message })
}
}

Expand All @@ -79,7 +66,6 @@ export const UserContactForm = observer(({ user }: Props) => {
<UserContactError submitResults={submitResults} />

<UserContactFieldName />
<UserContactFieldEmail email={email} />
<UserContactFieldMessage />

<Box>
Expand Down
1 change: 0 additions & 1 deletion src/pages/User/contact/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { UserContactError } from './UserContactError'
export { UserContactFieldEmail } from './UserContactFieldEmail'
export { UserContactFieldMessage } from './UserContactFieldMessage'
export { UserContactFieldName } from './UserContactFieldName'
export { UserContactNotLoggedIn } from './UserContactNotLoggedIn'
15 changes: 15 additions & 0 deletions src/services/message.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getFunctions, httpsCallable } from 'firebase/functions'

import type { SendMessage } from 'oa-shared'

const functions = getFunctions()

const sendMessageFunction = httpsCallable(functions, 'sendMessage')

const sendMessage = async (data: SendMessage) => {
await sendMessageFunction(data)
}

export const messageService = {
sendMessage,
}
Loading

0 comments on commit 8fa50e0

Please sign in to comment.