mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-12 23:35:52 +00:00
feat(backend): file upload chat backend (#8657)
* Prepare image uploads in chat * files instead of images * Fix file type and query * Add dummy data to resolver * fix graphql types * Fix file upload, remove unncessary code * Re-add fetch * Fix room order after sent message * Update backend/src/graphql/queries/messageQuery.ts * Move room to top of list when a message is received * working prototype chat file upload * remove console * allow to upload all kinds of files * multiple images * revert changes in S3 Images * tag mimetype * accept any file * lint fix * remove snapshot flakyness * remove whitelist test * fix messages spec * fix query * more query fixes * fix seed * made message resolver tests independent * lint * started specc for attachments * more tests & fixes * fix empty room error * remove console logs * fix tests * fix createRoom last Messsage error properly * lint fixes * reduce changeset * simplify config check * reduce changeset * missing change * allow speech capture * Fix file download * Implement proper download --------- Co-authored-by: Maximilian Harz <maxharz@gmail.com>
This commit is contained in:
parent
e6d3e5132e
commit
a0c205b379
@ -80,6 +80,18 @@ Factory.define('image')
|
||||
return neode.create('Image', buildObject)
|
||||
})
|
||||
|
||||
Factory.define('file')
|
||||
.attr('name', faker.lorem.slug)
|
||||
.attr('type', 'image/jpeg')
|
||||
.attr('url', null)
|
||||
.after((buildObject, _options) => {
|
||||
if (!buildObject.url) {
|
||||
buildObject.url = faker.image.urlPicsumPhotos()
|
||||
}
|
||||
buildObject.url = uniqueImageUrl(buildObject.url)
|
||||
return neode.create('File', buildObject)
|
||||
})
|
||||
|
||||
Factory.define('basicUser')
|
||||
.option('password', '1234')
|
||||
.attrs({
|
||||
|
||||
7
backend/src/db/models/File.ts
Normal file
7
backend/src/db/models/File.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
url: { primary: true, type: 'string', uri: { allowRelative: true } },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
}
|
||||
@ -5,4 +5,5 @@ export default {
|
||||
aspectRatio: { type: 'float', default: 1.0 },
|
||||
type: { type: 'string' },
|
||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare let Cypress: any | undefined
|
||||
export default {
|
||||
File: typeof Cypress !== 'undefined' ? require('./File') : require('./File').default,
|
||||
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
|
||||
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
|
||||
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,
|
||||
|
||||
@ -12,7 +12,7 @@ import { categories } from '@constants/categories'
|
||||
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
|
||||
import { createCommentMutation } from '@graphql/queries/createCommentMutation'
|
||||
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
|
||||
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
|
||||
import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createPostMutation } from '@graphql/queries/createPostMutation'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
|
||||
@ -1542,7 +1542,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
for (let i = 0; i < 30; i++) {
|
||||
authenticatedUser = await huey.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: roomHueyPeter?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
@ -1550,7 +1550,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
})
|
||||
authenticatedUser = await peterLustig.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: roomHueyPeter?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
@ -1568,7 +1568,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
authenticatedUser = await huey.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: roomHueyJenny?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
@ -1576,7 +1576,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
})
|
||||
authenticatedUser = await jennyRostock.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: roomHueyJenny?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
@ -1596,7 +1596,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
for (let i = 0; i < 29; i++) {
|
||||
authenticatedUser = await jennyRostock.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: room?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
@ -1604,7 +1604,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
|
||||
})
|
||||
authenticatedUser = await user.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: room?.CreateRoom.id,
|
||||
content: faker.lorem.sentence(),
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendChatMessageMail } from './sendEmail'
|
||||
|
||||
const senderUser = {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendEmailVerification } from './sendEmail'
|
||||
|
||||
describe('sendEmailVerification', () => {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendNotificationMail } from './sendEmail'
|
||||
|
||||
describe('sendNotificationMail', () => {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendRegistrationMail } from './sendEmail'
|
||||
|
||||
describe('sendRegistrationMail', () => {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendResetPasswordMail } from './sendEmail'
|
||||
|
||||
describe('sendResetPasswordMail', () => {
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
import CONFIG from '@config/index'
|
||||
|
||||
CONFIG.SUPPORT_EMAIL = 'devops@ocelot.social'
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { sendWrongEmail } from './sendEmail'
|
||||
|
||||
describe('sendWrongEmail', () => {
|
||||
|
||||
22
backend/src/graphql/queries/CreateMessage.ts
Normal file
22
backend/src/graphql/queries/CreateMessage.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const CreateMessage = gql`
|
||||
mutation ($roomId: ID!, $content: String!, $files: [FileInput]) {
|
||||
CreateMessage(roomId: $roomId, content: $content, files: $files) {
|
||||
id
|
||||
content
|
||||
senderId
|
||||
username
|
||||
avatar
|
||||
date
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
7
backend/src/graphql/queries/MarkMessagesAsSeen.ts
Normal file
7
backend/src/graphql/queries/MarkMessagesAsSeen.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const MarkMessagesAsSeen = gql`
|
||||
mutation ($messageIds: [String!]) {
|
||||
MarkMessagesAsSeen(messageIds: $messageIds)
|
||||
}
|
||||
`
|
||||
27
backend/src/graphql/queries/Message.ts
Normal file
27
backend/src/graphql/queries/Message.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const Message = gql`
|
||||
query ($roomId: ID!, $first: Int, $offset: Int) {
|
||||
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
|
||||
_id
|
||||
id
|
||||
indexId
|
||||
content
|
||||
senderId
|
||||
author {
|
||||
id
|
||||
}
|
||||
username
|
||||
avatar
|
||||
date
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -1,19 +0,0 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const createMessageMutation = () => {
|
||||
return gql`
|
||||
mutation ($roomId: ID!, $content: String!) {
|
||||
CreateMessage(roomId: $roomId, content: $content) {
|
||||
id
|
||||
content
|
||||
senderId
|
||||
username
|
||||
avatar
|
||||
date
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const markMessagesAsSeen = () => {
|
||||
return gql`
|
||||
mutation ($messageIds: [String!]) {
|
||||
MarkMessagesAsSeen(messageIds: $messageIds)
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const messageQuery = () => {
|
||||
return gql`
|
||||
query ($roomId: ID!, $first: Int, $offset: Int) {
|
||||
Message(roomId: $roomId, first: $first, offset: $offset, orderBy: indexId_desc) {
|
||||
_id
|
||||
id
|
||||
indexId
|
||||
content
|
||||
senderId
|
||||
author {
|
||||
id
|
||||
}
|
||||
username
|
||||
avatar
|
||||
date
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
350
backend/src/graphql/resolvers/attachments/attachments.spec.ts
Normal file
350
backend/src/graphql/resolvers/attachments/attachments.spec.ts
Normal file
@ -0,0 +1,350 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { ReadStream } from 'node:fs'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
import { UserInputError } from 'apollo-server'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
import databaseContext from '@context/database'
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import File from '@db/models/File'
|
||||
import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import type { S3Configured } from '@src/config'
|
||||
import createServer, { getContext } from '@src/server'
|
||||
|
||||
import { attachments } from './attachments'
|
||||
|
||||
import type { FileInput } from './attachments'
|
||||
|
||||
const s3SendMock = jest.fn()
|
||||
jest.spyOn(S3Client.prototype, 'send').mockImplementation(s3SendMock)
|
||||
|
||||
jest.mock('@aws-sdk/lib-storage')
|
||||
|
||||
const UploadMock = {
|
||||
done: () => {
|
||||
return {
|
||||
Location: 'http://your-objectstorage.com/bucket/',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
;(Upload as unknown as jest.Mock).mockImplementation(() => UploadMock)
|
||||
|
||||
const config: S3Configured = {
|
||||
AWS_ACCESS_KEY_ID: 'AWS_ACCESS_KEY_ID',
|
||||
AWS_SECRET_ACCESS_KEY: 'AWS_SECRET_ACCESS_KEY',
|
||||
AWS_BUCKET: 'AWS_BUCKET',
|
||||
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
||||
AWS_REGION: 'AWS_REGION',
|
||||
S3_PUBLIC_GATEWAY: undefined,
|
||||
}
|
||||
|
||||
const database = databaseContext()
|
||||
|
||||
let authenticatedUser, server, mutate
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanDatabase()
|
||||
|
||||
const contextUser = async (_req) => authenticatedUser
|
||||
const context = getContext({ user: contextUser, database })
|
||||
|
||||
server = createServer({ context }).server
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
void server.stop()
|
||||
void database.driver.close()
|
||||
database.neode.close()
|
||||
})
|
||||
|
||||
/* uploadCallback = jest.fn(
|
||||
({ uniqueFilename }) => `http://your-objectstorage.com/bucket/${uniqueFilename}`,
|
||||
)
|
||||
*/
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
describe('delete Attachment', () => {
|
||||
const { del: deleteAttachment } = attachments(config)
|
||||
describe('given a resource with an attachment', () => {
|
||||
let user: { id: string }
|
||||
let chatPartner: { id: string }
|
||||
let file: { id: string }
|
||||
let message: { id: string }
|
||||
beforeEach(async () => {
|
||||
const u = await Factory.build('user')
|
||||
user = await u.toJson()
|
||||
|
||||
const u2 = await Factory.build('user')
|
||||
chatPartner = await u2.toJson()
|
||||
|
||||
authenticatedUser = user
|
||||
const { data: room } = await mutate({
|
||||
mutation: createRoomMutation(),
|
||||
variables: {
|
||||
userId: chatPartner.id,
|
||||
},
|
||||
})
|
||||
|
||||
const f = await Factory.build('file', {
|
||||
url: 'http://localhost/some/file/url/',
|
||||
name: 'This is a file attached to a message',
|
||||
type: 'application/dummy',
|
||||
})
|
||||
file = await f.toJson()
|
||||
|
||||
const m = await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: room?.CreateRoom.id,
|
||||
content: 'test messsage',
|
||||
},
|
||||
})
|
||||
|
||||
message = m.data.CreateMessage
|
||||
|
||||
await database.write({
|
||||
query: `
|
||||
MATCH (message:Message {id: $message.id}), (file:File{url: $file.url})
|
||||
MERGE (message)-[:ATTACHMENT]->(file)
|
||||
`,
|
||||
variables: {
|
||||
message,
|
||||
file,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes `File` node', async () => {
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(1)
|
||||
await deleteAttachment(message, 'ATTACHMENT')
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('given a transaction parameter', () => {
|
||||
it('executes cypher statements within the transaction', async () => {
|
||||
const session = database.driver.session()
|
||||
let someString: string
|
||||
try {
|
||||
someString = await session.writeTransaction(async (transaction) => {
|
||||
await deleteAttachment(message, 'ATTACHMENT', {
|
||||
transaction,
|
||||
})
|
||||
const txResult = await transaction.run('RETURN "Hello" as result')
|
||||
const [result] = txResult.records.map((record) => record.get('result'))
|
||||
return result
|
||||
})
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(0)
|
||||
expect(someString).toEqual('Hello')
|
||||
})
|
||||
|
||||
it('rolls back the transaction in case of errors', async () => {
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(1)
|
||||
const session = database.driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
await deleteAttachment(message, 'ATTACHMENT', {
|
||||
transaction,
|
||||
})
|
||||
throw new Error('Ouch!')
|
||||
})
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (err) {
|
||||
// nothing has been deleted
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(1)
|
||||
// all good
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('add Attachment', () => {
|
||||
const { add: addAttachment } = attachments(config)
|
||||
let fileInput: FileInput
|
||||
let post: { id: string }
|
||||
beforeEach(() => {
|
||||
fileInput = {
|
||||
name: 'The name of the new attachment',
|
||||
type: 'application/any',
|
||||
}
|
||||
})
|
||||
|
||||
describe('given file.upload', () => {
|
||||
beforeEach(() => {
|
||||
const file1 = Readable.from('file1')
|
||||
fileInput = {
|
||||
...fileInput,
|
||||
// eslint-disable-next-line promise/avoid-new
|
||||
upload: new Promise((resolve) =>
|
||||
resolve({
|
||||
createReadStream: () => file1 as ReadStream,
|
||||
filename: 'file1',
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/json',
|
||||
}),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
describe('on existing resource', () => {
|
||||
beforeEach(async () => {
|
||||
const p = await Factory.build(
|
||||
'post',
|
||||
{ id: 'p99' },
|
||||
{
|
||||
author: Factory.build('user', {}, { avatar: null }),
|
||||
image: null,
|
||||
},
|
||||
)
|
||||
post = await p.toJson()
|
||||
})
|
||||
|
||||
it('returns new file', async () => {
|
||||
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).resolves.toMatchObject({
|
||||
updatedAt: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
name: 'The name of the new attachment',
|
||||
type: 'application/any',
|
||||
url: 'http://your-objectstorage.com/bucket/',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates `:File` node', async () => {
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(0)
|
||||
await addAttachment(post, 'ATTACHMENT', fileInput)
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('given a `S3_PUBLIC_GATEWAY` configuration', () => {
|
||||
const { add: addAttachment } = attachments({
|
||||
...config,
|
||||
S3_PUBLIC_GATEWAY: 'http://s3-public-gateway.com',
|
||||
})
|
||||
|
||||
it('changes the domain of the URL to a server that could e.g. apply image transformations', async () => {
|
||||
if (!fileInput.upload) {
|
||||
throw new Error('Test imageInput was not setup correctly.')
|
||||
}
|
||||
const upload = await fileInput.upload
|
||||
upload.filename = '/path/to/file-location/foo-bar-avatar.jpg'
|
||||
fileInput.upload = Promise.resolve(upload)
|
||||
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).resolves.toMatchObject({
|
||||
url: 'http://s3-public-gateway.com/bucket/',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('connects resource with image via given image type', async () => {
|
||||
await addAttachment(post, 'ATTACHMENT', fileInput)
|
||||
const result = await database.neode.cypher(
|
||||
`MATCH(p:Post {id: "p99"})-[:ATTACHMENT]->(f:File) RETURN f,p`,
|
||||
{},
|
||||
)
|
||||
post = database.neode
|
||||
.hydrateFirst<{ id: string }>(result, 'p', database.neode.model('Post'))
|
||||
.properties()
|
||||
const file = database.neode.hydrateFirst(result, 'f', database.neode.model('File'))
|
||||
expect(post).toBeTruthy()
|
||||
expect(file).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sets metadata', async () => {
|
||||
await addAttachment(post, 'ATTACHMENT', fileInput)
|
||||
const file = await database.neode.first<typeof File>('File', {}, undefined)
|
||||
await expect(file.toJson()).resolves.toMatchObject({
|
||||
name: 'The name of the new attachment',
|
||||
type: 'application/any',
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
url: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
describe('given a transaction parameter', () => {
|
||||
it('executes cypher statements within the transaction', async () => {
|
||||
const session = database.driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
const file = await addAttachment(
|
||||
post,
|
||||
'ATTACHMENT',
|
||||
fileInput,
|
||||
{},
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
return transaction.run(
|
||||
`
|
||||
MATCH(file:File {url: $file.url})
|
||||
SET file.name = 'This name text gets overwritten'
|
||||
RETURN file {.*}
|
||||
`,
|
||||
{ file },
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
const file = await database.neode.first<typeof File>(
|
||||
'File',
|
||||
{ name: 'This name text gets overwritten' },
|
||||
undefined,
|
||||
)
|
||||
await expect(file.toJson()).resolves.toMatchObject({
|
||||
name: 'This name text gets overwritten',
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back the transaction in case of errors', async () => {
|
||||
const session = database.driver.session()
|
||||
try {
|
||||
await session.writeTransaction(async (transaction) => {
|
||||
const file = await addAttachment(post, 'ATTACHMENT', fileInput, {
|
||||
transaction,
|
||||
})
|
||||
return transaction.run('Ooops invalid cypher!', { file })
|
||||
})
|
||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||
} catch (err) {
|
||||
// nothing has been created
|
||||
await expect(database.neode.all('File')).resolves.toHaveLength(0)
|
||||
// all good
|
||||
} finally {
|
||||
await session.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('without image.upload', () => {
|
||||
it('throws UserInputError', async () => {
|
||||
const p = await Factory.build('post', { id: 'p99' }, { image: null })
|
||||
post = await p.toJson()
|
||||
await expect(addAttachment(post, 'ATTACHMENT', fileInput)).rejects.toEqual(
|
||||
new UserInputError('Cannot find attachment for given resource'),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
173
backend/src/graphql/resolvers/attachments/attachments.ts
Normal file
173
backend/src/graphql/resolvers/attachments/attachments.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { DeleteObjectCommand, ObjectCannedACL, S3Client } from '@aws-sdk/client-s3'
|
||||
import { Upload } from '@aws-sdk/lib-storage'
|
||||
import { UserInputError } from 'apollo-server-express'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { isS3configured, S3Configured } from '@config/index'
|
||||
import { wrapTransaction } from '@graphql/resolvers/images/wrapTransaction'
|
||||
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
import type { Transaction } from 'neo4j-driver'
|
||||
|
||||
export type FileDeleteCallback = (url: string) => Promise<void>
|
||||
|
||||
export type FileUploadCallback = (
|
||||
upload: Pick<FileUpload, 'createReadStream' | 'mimetype'> & { uniqueFilename: string },
|
||||
) => Promise<string>
|
||||
export interface DeleteAttachmentsOpts {
|
||||
transaction?: Transaction
|
||||
}
|
||||
|
||||
export interface AddAttachmentOpts {
|
||||
transaction?: Transaction
|
||||
}
|
||||
|
||||
export interface FileInput {
|
||||
upload?: Promise<FileUpload>
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface File {
|
||||
url: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface Attachments {
|
||||
del: (
|
||||
resource: { id: string },
|
||||
relationshipType: 'ATTACHMENT',
|
||||
opts?: DeleteAttachmentsOpts,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<any>
|
||||
|
||||
add: (
|
||||
resource: { id: string },
|
||||
relationshipType: 'ATTACHMENT',
|
||||
file: FileInput,
|
||||
fileAttributes?: object,
|
||||
opts?: AddAttachmentOpts,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<any>
|
||||
}
|
||||
|
||||
export const attachments = (config: S3Configured) => {
|
||||
if (!isS3configured(config)) {
|
||||
throw new Error('S3 not configured')
|
||||
}
|
||||
|
||||
const { AWS_BUCKET: Bucket, S3_PUBLIC_GATEWAY } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config
|
||||
const s3 = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
endpoint: AWS_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
})
|
||||
|
||||
const del: Attachments['del'] = async (resource, relationshipType, opts = {}) => {
|
||||
const { transaction } = opts
|
||||
if (!transaction) return wrapTransaction(del, [resource, relationshipType], opts)
|
||||
const txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})-[rel:${relationshipType}]->(file:File)
|
||||
WITH file, file {.*} as fileProps
|
||||
DETACH DELETE file
|
||||
RETURN fileProps
|
||||
`,
|
||||
{ resource },
|
||||
)
|
||||
const [file] = txResult.records.map((record) => record.get('fileProps') as File)
|
||||
if (file) {
|
||||
let { pathname } = new URL(file.url, 'http://example.org') // dummy domain to avoid invalid URL error
|
||||
pathname = pathname.substring(1) // remove first character '/'
|
||||
const prefix = `${Bucket}/`
|
||||
if (pathname.startsWith(prefix)) {
|
||||
pathname = pathname.slice(prefix.length)
|
||||
}
|
||||
const params = {
|
||||
Bucket,
|
||||
Key: pathname,
|
||||
}
|
||||
await s3.send(new DeleteObjectCommand(params))
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
const add: Attachments['add'] = async (
|
||||
resource,
|
||||
relationshipType,
|
||||
fileInput,
|
||||
fileAttributes = {},
|
||||
opts = {},
|
||||
) => {
|
||||
const { transaction } = opts
|
||||
if (!transaction)
|
||||
return wrapTransaction(add, [resource, relationshipType, fileInput, fileAttributes], opts)
|
||||
|
||||
const { upload } = fileInput
|
||||
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
|
||||
|
||||
const uploadFile = await upload
|
||||
const { name: fileName, ext } = path.parse(uploadFile.filename)
|
||||
const uniqueFilename = `${uuid()}-${slug(fileName)}${ext}`
|
||||
|
||||
const s3Location = `attachments/${uniqueFilename}`
|
||||
const params = {
|
||||
Bucket,
|
||||
Key: s3Location,
|
||||
ACL: ObjectCannedACL.public_read,
|
||||
ContentType: uploadFile.mimetype,
|
||||
Body: uploadFile.createReadStream(),
|
||||
}
|
||||
const command = new Upload({ client: s3, params })
|
||||
const data = await command.done()
|
||||
const { Location } = data
|
||||
if (!Location) {
|
||||
throw new Error('File upload did not return `Location`')
|
||||
}
|
||||
|
||||
let url = ''
|
||||
if (!S3_PUBLIC_GATEWAY) {
|
||||
url = Location
|
||||
} else {
|
||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicLocation.pathname = new URL(Location).pathname
|
||||
url = publicLocation.href
|
||||
}
|
||||
|
||||
const { name, type } = fileInput
|
||||
const file = { url, name, type, ...fileAttributes }
|
||||
// const mimeType = uploadFile.mimetype.split('/')[0]
|
||||
// const nodeType = `Mime${mimeType.replace(/^./, mimeType[0].toUpperCase())}`
|
||||
// CREATE (file:${['File', nodeType].filter(Boolean).join(':')})
|
||||
const txResult = await transaction.run(
|
||||
`
|
||||
MATCH (resource {id: $resource.id})
|
||||
CREATE (file:File)
|
||||
SET file.createdAt = toString(datetime())
|
||||
SET file += $file
|
||||
SET file.updatedAt = toString(datetime())
|
||||
WITH resource, file
|
||||
MERGE (resource)-[:${relationshipType}]->(file)
|
||||
RETURN file {.*}
|
||||
`,
|
||||
{ resource, file /*, nodeType */ },
|
||||
)
|
||||
const [uploadedFile] = txResult.records.map((record) => record.get('file') as File)
|
||||
return uploadedFile
|
||||
}
|
||||
|
||||
const attachments: Attachments = {
|
||||
del,
|
||||
add,
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
@ -205,15 +205,6 @@ describe('mergeImage', () => {
|
||||
expect(image).toBeTruthy()
|
||||
})
|
||||
|
||||
it('whitelists relationship types', async () => {
|
||||
await expect(
|
||||
mergeImage(post, 'WHATEVER' as 'HERO_IMAGE', imageInput, {
|
||||
uploadCallback,
|
||||
deleteCallback,
|
||||
}),
|
||||
).rejects.toEqual(new Error('Unknown relationship type WHATEVER'))
|
||||
})
|
||||
|
||||
it('sets metadata', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
const image = await neode.first<typeof Image>('Image', {}, undefined)
|
||||
|
||||
@ -15,14 +15,12 @@ import { UserInputError } from 'apollo-server'
|
||||
import slug from 'slug'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
|
||||
import { wrapTransaction } from './wrapTransaction'
|
||||
|
||||
import type { Images, FileDeleteCallback, FileUploadCallback } from './images'
|
||||
import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
sanitizeRelationshipType(relationshipType)
|
||||
const { transaction, deleteCallback } = opts
|
||||
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
|
||||
const txResult = await transaction.run(
|
||||
@ -51,7 +49,6 @@ const mergeImage: Images['mergeImage'] = async (
|
||||
) => {
|
||||
if (typeof imageInput === 'undefined') return
|
||||
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
|
||||
sanitizeRelationshipType(relationshipType)
|
||||
const { transaction, uploadCallback, deleteCallback } = opts
|
||||
if (!transaction)
|
||||
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
|
||||
|
||||
@ -239,15 +239,6 @@ describe('mergeImage', () => {
|
||||
expect(image).toBeTruthy()
|
||||
})
|
||||
|
||||
it('whitelists relationship types', async () => {
|
||||
await expect(
|
||||
mergeImage(post, 'WHATEVER' as 'HERO_IMAGE', imageInput, {
|
||||
uploadCallback,
|
||||
deleteCallback,
|
||||
}),
|
||||
).rejects.toEqual(new Error('Unknown relationship type WHATEVER'))
|
||||
})
|
||||
|
||||
it('sets metadata', async () => {
|
||||
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
|
||||
const image = await neode.first<typeof Image>('Image', {}, undefined)
|
||||
|
||||
@ -8,7 +8,6 @@ import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { S3Configured } from '@config/index'
|
||||
|
||||
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
|
||||
import { wrapTransaction } from './wrapTransaction'
|
||||
|
||||
import type { Image, Images, FileDeleteCallback, FileUploadCallback } from './images'
|
||||
@ -29,7 +28,6 @@ export const images = (config: S3Configured) => {
|
||||
})
|
||||
|
||||
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
|
||||
sanitizeRelationshipType(relationshipType)
|
||||
const { transaction, deleteCallback = s3Delete } = opts
|
||||
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
|
||||
const txResult = await transaction.run(
|
||||
@ -60,7 +58,6 @@ export const images = (config: S3Configured) => {
|
||||
) => {
|
||||
if (typeof imageInput === 'undefined') return
|
||||
if (imageInput === null) return deleteImage(resource, relationshipType, opts)
|
||||
sanitizeRelationshipType(relationshipType)
|
||||
const { transaction, uploadCallback, deleteCallback = s3Delete } = opts
|
||||
if (!transaction)
|
||||
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
export function sanitizeRelationshipType(
|
||||
relationshipType: string,
|
||||
): asserts relationshipType is 'HERO_IMAGE' | 'AVATAR_IMAGE' {
|
||||
// Cypher query language does not allow to parameterize relationship types
|
||||
// See: https://github.com/neo4j/neo4j/issues/340
|
||||
if (!['HERO_IMAGE', 'AVATAR_IMAGE'].includes(relationshipType)) {
|
||||
throw new Error(`Unknown relationship type ${relationshipType}`)
|
||||
}
|
||||
}
|
||||
@ -3,16 +3,19 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import { Upload } from 'graphql-upload/public/index'
|
||||
|
||||
import databaseContext from '@context/database'
|
||||
import pubsubContext from '@context/pubsub'
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
|
||||
import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import { markMessagesAsSeen } from '@graphql/queries/markMessagesAsSeen'
|
||||
import { messageQuery } from '@graphql/queries/messageQuery'
|
||||
import { MarkMessagesAsSeen } from '@graphql/queries/MarkMessagesAsSeen'
|
||||
import { Message } from '@graphql/queries/Message'
|
||||
import { roomQuery } from '@graphql/queries/roomQuery'
|
||||
import createServer, { getContext } from '@src/server'
|
||||
|
||||
@ -39,6 +42,10 @@ beforeAll(async () => {
|
||||
mutate = createTestClient(server).mutate
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDatabase()
|
||||
void server.stop()
|
||||
@ -49,7 +56,7 @@ afterAll(async () => {
|
||||
describe('Message', () => {
|
||||
let roomId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
|
||||
Factory.build('user', {
|
||||
id: 'chatting-user',
|
||||
@ -75,7 +82,7 @@ describe('Message', () => {
|
||||
it('throws authorization error', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: 'some-id',
|
||||
content: 'Some bla bla bla',
|
||||
@ -96,7 +103,7 @@ describe('Message', () => {
|
||||
it('returns null and does not publish subscription', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: 'some-id',
|
||||
content: 'Some bla bla bla',
|
||||
@ -113,7 +120,8 @@ describe('Message', () => {
|
||||
})
|
||||
|
||||
describe('room exists', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = await chattingUser.toJson()
|
||||
const room = await mutate({
|
||||
mutation: createRoomMutation(),
|
||||
variables: {
|
||||
@ -127,7 +135,7 @@ describe('Message', () => {
|
||||
it('returns the message', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to other chatting user',
|
||||
@ -151,6 +159,16 @@ describe('Message', () => {
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to other chatting user',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('room is updated as well', () => {
|
||||
it('has last message set', async () => {
|
||||
const result = await query({ query: roomQuery() })
|
||||
@ -210,15 +228,70 @@ describe('Message', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('user sends files in room', () => {
|
||||
const file1 = Readable.from('file1')
|
||||
const upload1 = new Upload()
|
||||
upload1.resolve({
|
||||
createReadStream: () => file1,
|
||||
stream: file1,
|
||||
filename: 'file1',
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/json',
|
||||
})
|
||||
const file2 = Readable.from('file2')
|
||||
const upload2 = new Upload()
|
||||
upload2.resolve({
|
||||
createReadStream: () => file2,
|
||||
stream: file2,
|
||||
filename: 'file2',
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
})
|
||||
it('returns the message', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some files for other chatting user',
|
||||
files: [
|
||||
{ upload: upload1, name: 'test1', type: 'application/json' },
|
||||
{ upload: upload2, name: 'test2', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: undefined,
|
||||
data: {
|
||||
CreateMessage: {
|
||||
id: expect.any(String),
|
||||
content: 'Some files for other chatting user',
|
||||
senderId: 'chatting-user',
|
||||
username: 'Chatting User',
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
saved: true,
|
||||
distributed: false,
|
||||
seen: false,
|
||||
files: expect.arrayContaining([
|
||||
{ name: 'test1', type: 'application/json', url: expect.any(String) },
|
||||
{ name: 'test2', type: 'image/png', url: expect.any(String) },
|
||||
]),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user does not chat in room', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = await notChattingUser.toJson()
|
||||
})
|
||||
|
||||
it('returns null', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'I have no access to this room!',
|
||||
@ -245,7 +318,7 @@ describe('Message', () => {
|
||||
it('throws authorization error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId: 'some-id',
|
||||
},
|
||||
@ -265,7 +338,7 @@ describe('Message', () => {
|
||||
it('returns null', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId: 'some-id',
|
||||
},
|
||||
@ -280,9 +353,28 @@ describe('Message', () => {
|
||||
})
|
||||
|
||||
describe('room exists with authenticated user chatting', () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = await chattingUser.toJson()
|
||||
const room = await mutate({
|
||||
mutation: createRoomMutation(),
|
||||
variables: {
|
||||
userId: 'other-chatting-user',
|
||||
},
|
||||
})
|
||||
roomId = room.data.CreateRoom.id
|
||||
|
||||
await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to other chatting user',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the messages', async () => {
|
||||
const result = await query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
},
|
||||
@ -301,7 +393,7 @@ describe('Message', () => {
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
saved: true,
|
||||
distributed: true,
|
||||
distributed: false,
|
||||
seen: false,
|
||||
},
|
||||
],
|
||||
@ -310,9 +402,10 @@ describe('Message', () => {
|
||||
})
|
||||
|
||||
describe('more messages', () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = await otherChattingUser.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'A nice response message to chatting user',
|
||||
@ -320,7 +413,7 @@ describe('Message', () => {
|
||||
})
|
||||
authenticatedUser = await chattingUser.toJson()
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'And another nice message to other chatting user',
|
||||
@ -331,7 +424,7 @@ describe('Message', () => {
|
||||
it('returns the messages', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
},
|
||||
@ -349,7 +442,7 @@ describe('Message', () => {
|
||||
avatar: expect.any(String),
|
||||
date: expect.any(String),
|
||||
saved: true,
|
||||
distributed: true,
|
||||
distributed: false,
|
||||
seen: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@ -384,7 +477,7 @@ describe('Message', () => {
|
||||
it('returns the messages paginated', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
first: 2,
|
||||
@ -419,7 +512,7 @@ describe('Message', () => {
|
||||
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
first: 2,
|
||||
@ -454,7 +547,7 @@ describe('Message', () => {
|
||||
it('returns null', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
},
|
||||
@ -479,7 +572,7 @@ describe('Message', () => {
|
||||
it('throws authorization error', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: markMessagesAsSeen(),
|
||||
mutation: MarkMessagesAsSeen,
|
||||
variables: {
|
||||
messageIds: ['some-id'],
|
||||
},
|
||||
@ -492,10 +585,41 @@ describe('Message', () => {
|
||||
|
||||
describe('authenticated', () => {
|
||||
const messageIds: string[] = []
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
authenticatedUser = await chattingUser.toJson()
|
||||
const room = await mutate({
|
||||
mutation: createRoomMutation(),
|
||||
variables: {
|
||||
userId: 'other-chatting-user',
|
||||
},
|
||||
})
|
||||
roomId = room.data.CreateRoom.id
|
||||
await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to other chatting user',
|
||||
},
|
||||
})
|
||||
authenticatedUser = await otherChattingUser.toJson()
|
||||
await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'A nice response message to chatting user',
|
||||
},
|
||||
})
|
||||
authenticatedUser = await chattingUser.toJson()
|
||||
await mutate({
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'And another nice message to other chatting user',
|
||||
},
|
||||
})
|
||||
authenticatedUser = await otherChattingUser.toJson()
|
||||
const msgs = await query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
},
|
||||
@ -506,7 +630,7 @@ describe('Message', () => {
|
||||
it('returns true', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: markMessagesAsSeen(),
|
||||
mutation: MarkMessagesAsSeen,
|
||||
variables: {
|
||||
messageIds,
|
||||
},
|
||||
@ -520,9 +644,15 @@ describe('Message', () => {
|
||||
})
|
||||
|
||||
it('has seen prop set to true', async () => {
|
||||
await mutate({
|
||||
mutation: MarkMessagesAsSeen,
|
||||
variables: {
|
||||
messageIds,
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
query({
|
||||
query: messageQuery(),
|
||||
query: Message,
|
||||
variables: {
|
||||
roomId,
|
||||
},
|
||||
|
||||
@ -7,8 +7,10 @@
|
||||
import { withFilter } from 'graphql-subscriptions'
|
||||
import { neo4jgraphql } from 'neo4j-graphql-js'
|
||||
|
||||
import CONFIG, { isS3configured } from '@config/index'
|
||||
import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
|
||||
|
||||
import { attachments } from './attachments/attachments'
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
|
||||
@ -70,7 +72,7 @@ export default {
|
||||
},
|
||||
Mutation: {
|
||||
CreateMessage: async (_parent, params, context, _resolveInfo) => {
|
||||
const { roomId, content } = params
|
||||
const { roomId, content, files = [] } = params
|
||||
const {
|
||||
user: { id: currentUserId },
|
||||
} = context
|
||||
@ -82,7 +84,7 @@ export default {
|
||||
OPTIONAL MATCH (m:Message)-[:INSIDE]->(room)
|
||||
OPTIONAL MATCH (room)<-[:CHATS_IN]-(recipientUser:User)
|
||||
WHERE NOT recipientUser.id = $currentUserId
|
||||
WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
|
||||
WITH MAX(m.indexId) as maxIndex, room, currentUser, image, recipientUser
|
||||
CREATE (currentUser)-[:CREATED]->(message:Message {
|
||||
createdAt: toString(datetime()),
|
||||
id: apoc.create.uuid(),
|
||||
@ -116,7 +118,40 @@ export default {
|
||||
return message
|
||||
})
|
||||
try {
|
||||
return await writeTxResultPromise
|
||||
// We cannot combine the query above with the attachments, since you need the resource for matching
|
||||
const message = await writeTxResultPromise
|
||||
|
||||
// this is the case if the room doesn't exist - requires refactoring for implicit rooms
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = context.driver.session()
|
||||
const writeFilesPromise = session.writeTransaction(async (transaction) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const atns: any[] = []
|
||||
|
||||
if (!isS3configured(CONFIG)) {
|
||||
return atns
|
||||
}
|
||||
|
||||
for await (const file of files) {
|
||||
const atn = await attachments(CONFIG).add(
|
||||
message,
|
||||
'ATTACHMENT',
|
||||
file,
|
||||
{},
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
)
|
||||
atns.push(atn)
|
||||
}
|
||||
return atns
|
||||
})
|
||||
|
||||
const atns = await writeFilesPromise
|
||||
return { ...message, files: atns }
|
||||
} catch (error) {
|
||||
throw new Error(error)
|
||||
} finally {
|
||||
@ -156,6 +191,9 @@ export default {
|
||||
author: '<-[:CREATED]-(related:User)',
|
||||
room: '-[:INSIDE]->(related:Room)',
|
||||
},
|
||||
hasMany: {
|
||||
files: '-[:ATTACHMENT]-(related:File)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
|
||||
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import { getNeode, getDriver } from '@db/neo4j'
|
||||
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
|
||||
import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import { roomQuery } from '@graphql/queries/roomQuery'
|
||||
import { unreadRoomsQuery } from '@graphql/queries/unreadRoomsQuery'
|
||||
@ -327,21 +327,21 @@ describe('Room', () => {
|
||||
})
|
||||
otherRoomId = result.data.CreateRoom.roomId
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: otherRoomId,
|
||||
content: 'Message to not chatting user',
|
||||
},
|
||||
})
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: '1st message to other chatting user',
|
||||
},
|
||||
})
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: '2nd message to other chatting user',
|
||||
@ -356,7 +356,7 @@ describe('Room', () => {
|
||||
})
|
||||
otherRoomId = result2.data.CreateRoom.roomId
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId: otherRoomId,
|
||||
content: 'Other message to not chatting user',
|
||||
|
||||
16
backend/src/graphql/types/type/File.gql
Normal file
16
backend/src/graphql/types/type/File.gql
Normal file
@ -0,0 +1,16 @@
|
||||
type File {
|
||||
url: ID!,
|
||||
name: String,
|
||||
#size: Int,
|
||||
type: String,
|
||||
#audio: Boolean,
|
||||
#duration: Float,
|
||||
#preview: String,
|
||||
#progress: Int,
|
||||
}
|
||||
|
||||
input FileInput {
|
||||
upload: Upload,
|
||||
name: String,
|
||||
type: String,
|
||||
}
|
||||
@ -12,8 +12,8 @@ type Message {
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
|
||||
content: String!
|
||||
|
||||
content: String
|
||||
|
||||
author: User! @relation(name: "CREATED", direction: "IN")
|
||||
room: Room! @relation(name: "INSIDE", direction: "OUT")
|
||||
|
||||
@ -25,12 +25,14 @@ type Message {
|
||||
saved: Boolean
|
||||
distributed: Boolean
|
||||
seen: Boolean
|
||||
files: [File]! @relation(name: "ATTACHMENT", direction: "OUT")
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
CreateMessage(
|
||||
roomId: ID!
|
||||
content: String!
|
||||
content: String
|
||||
files: [FileInput]
|
||||
): Message
|
||||
|
||||
MarkMessagesAsSeen(messageIds: [String!]): Boolean
|
||||
|
||||
@ -13,7 +13,7 @@ import pubsubContext from '@context/pubsub'
|
||||
import Factory, { cleanDatabase } from '@db/factories'
|
||||
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
|
||||
import { createGroupMutation } from '@graphql/queries/createGroupMutation'
|
||||
import { createMessageMutation } from '@graphql/queries/createMessageMutation'
|
||||
import { CreateMessage } from '@graphql/queries/CreateMessage'
|
||||
import { createRoomMutation } from '@graphql/queries/createRoomMutation'
|
||||
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
|
||||
import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation'
|
||||
@ -918,7 +918,7 @@ describe('notifications', () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(true)
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
@ -953,7 +953,7 @@ describe('notifications', () => {
|
||||
isUserOnlineMock = jest.fn().mockReturnValue(false)
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
@ -1002,7 +1002,7 @@ describe('notifications', () => {
|
||||
await chatReceiver.relateTo(chatSender, 'blocked')
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
@ -1022,7 +1022,7 @@ describe('notifications', () => {
|
||||
await chatReceiver.relateTo(chatSender, 'muted')
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
@ -1042,7 +1042,7 @@ describe('notifications', () => {
|
||||
await chatReceiver.update({ emailNotificationsChatMessage: false })
|
||||
|
||||
await mutate({
|
||||
mutation: createMessageMutation(),
|
||||
mutation: CreateMessage,
|
||||
variables: {
|
||||
roomId,
|
||||
content: 'Some nice message to chatReceiver',
|
||||
|
||||
@ -41,6 +41,7 @@ services:
|
||||
- AWS_REGION=local
|
||||
- AWS_BUCKET=ocelot
|
||||
- S3_PUBLIC_GATEWAY=http:/localhost:9000
|
||||
- DEBUG=neo4j-graphql-js
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
|
||||
|
||||
@ -15,8 +15,9 @@
|
||||
:room-actions="JSON.stringify(roomActions)"
|
||||
:rooms-loaded="roomsLoaded"
|
||||
:loading-rooms="loadingRooms"
|
||||
show-files="false"
|
||||
show-audio="false"
|
||||
show-files="true"
|
||||
show-audio="true"
|
||||
capture-files="true"
|
||||
:height="'calc(100dvh - 190px)'"
|
||||
:styles="JSON.stringify(computedChatStyle)"
|
||||
:show-footer="true"
|
||||
@ -29,6 +30,7 @@
|
||||
@add-room="toggleUserSearch"
|
||||
@show-demo-options="showDemoOptions = $event"
|
||||
@open-user-tag="redirectToUserProfile($event.detail[0])"
|
||||
@open-file="openFile($event.detail[0].file.file)"
|
||||
>
|
||||
<div
|
||||
v-if="selectedRoom && selectedRoom.roomId"
|
||||
@ -356,6 +358,8 @@ export default {
|
||||
changedRoom.lastMessage = data.chatMessageAdded
|
||||
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
|
||||
changedRoom.lastMessageAt = data.chatMessageAdded.date
|
||||
// Move changed room to the top of the list
|
||||
changedRoom.index = data.chatMessageAdded.date
|
||||
changedRoom.unreadCount++
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
if (data.chatMessageAdded.room.id === this.selectedRoom?.id) {
|
||||
@ -365,31 +369,52 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage(message) {
|
||||
async sendMessage(messageDetails) {
|
||||
const { roomId, content, files } = messageDetails
|
||||
|
||||
const hasFiles = files && files.length > 0
|
||||
|
||||
const filesToUpload = hasFiles
|
||||
? files.map((file) => ({
|
||||
upload: file.blob,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
}))
|
||||
: null
|
||||
|
||||
const mutationVariables = {
|
||||
roomId,
|
||||
content,
|
||||
}
|
||||
|
||||
if (filesToUpload && filesToUpload.length > 0) {
|
||||
mutationVariables.files = filesToUpload
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { CreateMessage: createdMessage },
|
||||
} = await this.$apollo.mutate({
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: createMessageMutation(),
|
||||
variables: {
|
||||
roomId: message.roomId,
|
||||
content: message.content,
|
||||
},
|
||||
variables: mutationVariables,
|
||||
})
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === message.roomId)
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage = createdMessage
|
||||
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
|
||||
// move current room to top (not 100% working)
|
||||
// const rooms = [...this.rooms]
|
||||
// rooms.splice(roomIndex,1)
|
||||
// this.rooms = [changedRoom, ...rooms]
|
||||
this.rooms[roomIndex] = changedRoom
|
||||
const createdMessagePayload = data.CreateMessage
|
||||
|
||||
if (createdMessagePayload) {
|
||||
const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
|
||||
if (roomIndex !== -1) {
|
||||
const changedRoom = { ...this.rooms[roomIndex] }
|
||||
changedRoom.lastMessage.content = createdMessagePayload.content.trim().substring(0, 30)
|
||||
changedRoom.lastMessage.date = createdMessagePayload.date
|
||||
|
||||
// Move changed room to the top of the list
|
||||
changedRoom.index = createdMessagePayload.date
|
||||
this.rooms = [changedRoom, ...this.rooms.filter((r) => r.id !== roomId)]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.$toast.error(error.message)
|
||||
}
|
||||
|
||||
this.fetchMessages({
|
||||
room: this.rooms.find((r) => r.roomId === message.roomId),
|
||||
room: this.rooms.find((r) => r.roomId === messageDetails.roomId),
|
||||
options: { refetch: true },
|
||||
})
|
||||
},
|
||||
@ -414,7 +439,7 @@ export default {
|
||||
...room.lastMessage,
|
||||
content: room.lastMessage?.content?.trim().substring(0, 30),
|
||||
}
|
||||
: null,
|
||||
: {},
|
||||
users: room.users.map((u) => {
|
||||
return { ...u, username: u.name, avatar: this.$filters.proxyApiUrl(u.avatar?.url) }
|
||||
}),
|
||||
@ -452,6 +477,30 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
openFile: async function (file) {
|
||||
if (!file || !file.url) return
|
||||
/* To make the browser download the file instead of opening it, it needs to be
|
||||
from the same origin or from local blob storage. So we fetch it first
|
||||
and then create a download link from blob storage. */
|
||||
|
||||
const url = this.$filters.proxyApiUrl(file.url)
|
||||
const download = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
const blob = await download.blob()
|
||||
const objectURL = window.URL.createObjectURL(blob)
|
||||
const downloadLink = document.createElement('a')
|
||||
downloadLink.href = objectURL
|
||||
downloadLink.download = `${file.name}.${file.type.split('/')[1]}`
|
||||
downloadLink.style.display = 'none'
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
},
|
||||
|
||||
redirectToUserProfile({ user }) {
|
||||
const userID = user.id
|
||||
const userName = user.name.toLowerCase().replaceAll(' ', '-')
|
||||
|
||||
@ -2,8 +2,8 @@ import gql from 'graphql-tag'
|
||||
|
||||
export const createMessageMutation = () => {
|
||||
return gql`
|
||||
mutation ($roomId: ID!, $content: String!) {
|
||||
CreateMessage(roomId: $roomId, content: $content) {
|
||||
mutation ($roomId: ID!, $content: String, $files: [FileInput]) {
|
||||
CreateMessage(roomId: $roomId, content: $content, files: $files) {
|
||||
#_id
|
||||
id
|
||||
indexId
|
||||
@ -21,6 +21,13 @@ export const createMessageMutation = () => {
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
#size
|
||||
type
|
||||
#preview
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -47,6 +54,15 @@ export const messageQuery = () => {
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
#size
|
||||
type
|
||||
#audio
|
||||
#duration
|
||||
#preview
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -73,6 +89,15 @@ export const chatMessageAdded = () => {
|
||||
saved
|
||||
distributed
|
||||
seen
|
||||
files {
|
||||
url
|
||||
name
|
||||
#size
|
||||
type
|
||||
#audio
|
||||
#duration
|
||||
#preview
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user