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:
Ulf Gebhardt 2025-06-13 21:02:37 +02:00 committed by GitHub
parent e6d3e5132e
commit a0c205b379
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 965 additions and 159 deletions

View File

@ -80,6 +80,18 @@ Factory.define('image')
return neode.create('Image', buildObject) 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') Factory.define('basicUser')
.option('password', '1234') .option('password', '1234')
.attrs({ .attrs({

View 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() },
}

View File

@ -5,4 +5,5 @@ export default {
aspectRatio: { type: 'float', default: 1.0 }, aspectRatio: { type: 'float', default: 1.0 },
type: { type: 'string' }, type: { type: 'string' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
} }

View File

@ -8,6 +8,7 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let Cypress: any | undefined declare let Cypress: any | undefined
export default { export default {
File: typeof Cypress !== 'undefined' ? require('./File') : require('./File').default,
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default, Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default, Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default, User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,

View File

@ -12,7 +12,7 @@ import { categories } from '@constants/categories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation' import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createCommentMutation } from '@graphql/queries/createCommentMutation' import { createCommentMutation } from '@graphql/queries/createCommentMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation' 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 { createPostMutation } from '@graphql/queries/createPostMutation'
import { createRoomMutation } from '@graphql/queries/createRoomMutation' import { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation' 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++) { for (let i = 0; i < 30; i++) {
authenticatedUser = await huey.toJson() authenticatedUser = await huey.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: roomHueyPeter?.CreateRoom.id, roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),
@ -1550,7 +1550,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}) })
authenticatedUser = await peterLustig.toJson() authenticatedUser = await peterLustig.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: roomHueyPeter?.CreateRoom.id, roomId: roomHueyPeter?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),
@ -1568,7 +1568,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
authenticatedUser = await huey.toJson() authenticatedUser = await huey.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: roomHueyJenny?.CreateRoom.id, roomId: roomHueyJenny?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),
@ -1576,7 +1576,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}) })
authenticatedUser = await jennyRostock.toJson() authenticatedUser = await jennyRostock.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: roomHueyJenny?.CreateRoom.id, roomId: roomHueyJenny?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),
@ -1596,7 +1596,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
for (let i = 0; i < 29; i++) { for (let i = 0; i < 29; i++) {
authenticatedUser = await jennyRostock.toJson() authenticatedUser = await jennyRostock.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: room?.CreateRoom.id, roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),
@ -1604,7 +1604,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
}) })
authenticatedUser = await user.toJson() authenticatedUser = await user.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: room?.CreateRoom.id, roomId: room?.CreateRoom.id,
content: faker.lorem.sentence(), content: faker.lorem.sentence(),

View File

@ -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' import { sendChatMessageMail } from './sendEmail'
const senderUser = { const senderUser = {

View File

@ -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' import { sendEmailVerification } from './sendEmail'
describe('sendEmailVerification', () => { describe('sendEmailVerification', () => {

View File

@ -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' import { sendNotificationMail } from './sendEmail'
describe('sendNotificationMail', () => { describe('sendNotificationMail', () => {

View File

@ -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' import { sendRegistrationMail } from './sendEmail'
describe('sendRegistrationMail', () => { describe('sendRegistrationMail', () => {

View File

@ -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' import { sendResetPasswordMail } from './sendEmail'
describe('sendResetPasswordMail', () => { describe('sendResetPasswordMail', () => {

View File

@ -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' import { sendWrongEmail } from './sendEmail'
describe('sendWrongEmail', () => { describe('sendWrongEmail', () => {

View 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
}
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const MarkMessagesAsSeen = gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`

View 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
}
}
}
`

View File

@ -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
}
}
`
}

View File

@ -1,9 +0,0 @@
import gql from 'graphql-tag'
export const markMessagesAsSeen = () => {
return gql`
mutation ($messageIds: [String!]) {
MarkMessagesAsSeen(messageIds: $messageIds)
}
`
}

View File

@ -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
}
}
`
}

View 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'),
)
})
})
})

View 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
}

View File

@ -205,15 +205,6 @@ describe('mergeImage', () => {
expect(image).toBeTruthy() 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 () => { it('sets metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const image = await neode.first<typeof Image>('Image', {}, undefined) const image = await neode.first<typeof Image>('Image', {}, undefined)

View File

@ -15,14 +15,12 @@ import { UserInputError } from 'apollo-server'
import slug from 'slug' import slug from 'slug'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
import { wrapTransaction } from './wrapTransaction' import { wrapTransaction } from './wrapTransaction'
import type { Images, FileDeleteCallback, FileUploadCallback } from './images' import type { Images, FileDeleteCallback, FileUploadCallback } from './images'
import type { FileUpload } from 'graphql-upload' import type { FileUpload } from 'graphql-upload'
const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => { const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
sanitizeRelationshipType(relationshipType)
const { transaction, deleteCallback } = opts const { transaction, deleteCallback } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts) if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run( const txResult = await transaction.run(
@ -51,7 +49,6 @@ const mergeImage: Images['mergeImage'] = async (
) => { ) => {
if (typeof imageInput === 'undefined') return if (typeof imageInput === 'undefined') return
if (imageInput === null) return deleteImage(resource, relationshipType, opts) if (imageInput === null) return deleteImage(resource, relationshipType, opts)
sanitizeRelationshipType(relationshipType)
const { transaction, uploadCallback, deleteCallback } = opts const { transaction, uploadCallback, deleteCallback } = opts
if (!transaction) if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts) return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)

View File

@ -239,15 +239,6 @@ describe('mergeImage', () => {
expect(image).toBeTruthy() 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 () => { it('sets metadata', async () => {
await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback }) await mergeImage(post, 'HERO_IMAGE', imageInput, { uploadCallback, deleteCallback })
const image = await neode.first<typeof Image>('Image', {}, undefined) const image = await neode.first<typeof Image>('Image', {}, undefined)

View File

@ -8,7 +8,6 @@ import { v4 as uuid } from 'uuid'
import { S3Configured } from '@config/index' import { S3Configured } from '@config/index'
import { sanitizeRelationshipType } from './sanitizeRelationshipTypes'
import { wrapTransaction } from './wrapTransaction' import { wrapTransaction } from './wrapTransaction'
import type { Image, Images, FileDeleteCallback, FileUploadCallback } from './images' 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 = {}) => { const deleteImage: Images['deleteImage'] = async (resource, relationshipType, opts = {}) => {
sanitizeRelationshipType(relationshipType)
const { transaction, deleteCallback = s3Delete } = opts const { transaction, deleteCallback = s3Delete } = opts
if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts) if (!transaction) return wrapTransaction(deleteImage, [resource, relationshipType], opts)
const txResult = await transaction.run( const txResult = await transaction.run(
@ -60,7 +58,6 @@ export const images = (config: S3Configured) => {
) => { ) => {
if (typeof imageInput === 'undefined') return if (typeof imageInput === 'undefined') return
if (imageInput === null) return deleteImage(resource, relationshipType, opts) if (imageInput === null) return deleteImage(resource, relationshipType, opts)
sanitizeRelationshipType(relationshipType)
const { transaction, uploadCallback, deleteCallback = s3Delete } = opts const { transaction, uploadCallback, deleteCallback = s3Delete } = opts
if (!transaction) if (!transaction)
return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts) return wrapTransaction(mergeImage, [resource, relationshipType, imageInput], opts)

View File

@ -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}`)
}
}

View File

@ -3,16 +3,19 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Readable } from 'node:stream'
import { ApolloServer } from 'apollo-server-express' import { ApolloServer } from 'apollo-server-express'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import { Upload } from 'graphql-upload/public/index'
import databaseContext from '@context/database' import databaseContext from '@context/database'
import pubsubContext from '@context/pubsub' import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories' 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 { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { markMessagesAsSeen } from '@graphql/queries/markMessagesAsSeen' import { MarkMessagesAsSeen } from '@graphql/queries/MarkMessagesAsSeen'
import { messageQuery } from '@graphql/queries/messageQuery' import { Message } from '@graphql/queries/Message'
import { roomQuery } from '@graphql/queries/roomQuery' import { roomQuery } from '@graphql/queries/roomQuery'
import createServer, { getContext } from '@src/server' import createServer, { getContext } from '@src/server'
@ -39,6 +42,10 @@ beforeAll(async () => {
mutate = createTestClient(server).mutate mutate = createTestClient(server).mutate
}) })
beforeEach(async () => {
await cleanDatabase()
})
afterAll(async () => { afterAll(async () => {
await cleanDatabase() await cleanDatabase()
void server.stop() void server.stop()
@ -49,7 +56,7 @@ afterAll(async () => {
describe('Message', () => { describe('Message', () => {
let roomId: string let roomId: string
beforeAll(async () => { beforeEach(async () => {
;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([
Factory.build('user', { Factory.build('user', {
id: 'chatting-user', id: 'chatting-user',
@ -75,7 +82,7 @@ describe('Message', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect( await expect(
mutate({ mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: 'some-id', roomId: 'some-id',
content: 'Some bla bla bla', content: 'Some bla bla bla',
@ -96,7 +103,7 @@ describe('Message', () => {
it('returns null and does not publish subscription', async () => { it('returns null and does not publish subscription', async () => {
await expect( await expect(
mutate({ mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: 'some-id', roomId: 'some-id',
content: 'Some bla bla bla', content: 'Some bla bla bla',
@ -113,7 +120,8 @@ describe('Message', () => {
}) })
describe('room exists', () => { describe('room exists', () => {
beforeAll(async () => { beforeEach(async () => {
authenticatedUser = await chattingUser.toJson()
const room = await mutate({ const room = await mutate({
mutation: createRoomMutation(), mutation: createRoomMutation(),
variables: { variables: {
@ -127,7 +135,7 @@ describe('Message', () => {
it('returns the message', async () => { it('returns the message', async () => {
await expect( await expect(
mutate({ mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to other chatting user', 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', () => { describe('room is updated as well', () => {
it('has last message set', async () => { it('has last message set', async () => {
const result = await query({ query: roomQuery() }) 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', () => { describe('user does not chat in room', () => {
beforeAll(async () => { beforeEach(async () => {
authenticatedUser = await notChattingUser.toJson() authenticatedUser = await notChattingUser.toJson()
}) })
it('returns null', async () => { it('returns null', async () => {
await expect( await expect(
mutate({ mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'I have no access to this room!', content: 'I have no access to this room!',
@ -245,7 +318,7 @@ describe('Message', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId: 'some-id', roomId: 'some-id',
}, },
@ -265,7 +338,7 @@ describe('Message', () => {
it('returns null', async () => { it('returns null', async () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId: 'some-id', roomId: 'some-id',
}, },
@ -280,9 +353,28 @@ describe('Message', () => {
}) })
describe('room exists with authenticated user chatting', () => { 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 () => { it('returns the messages', async () => {
const result = await query({ const result = await query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
}, },
@ -301,7 +393,7 @@ describe('Message', () => {
avatar: expect.any(String), avatar: expect.any(String),
date: expect.any(String), date: expect.any(String),
saved: true, saved: true,
distributed: true, distributed: false,
seen: false, seen: false,
}, },
], ],
@ -310,9 +402,10 @@ describe('Message', () => {
}) })
describe('more messages', () => { describe('more messages', () => {
beforeAll(async () => { beforeEach(async () => {
authenticatedUser = await otherChattingUser.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'A nice response message to chatting user', content: 'A nice response message to chatting user',
@ -320,7 +413,7 @@ describe('Message', () => {
}) })
authenticatedUser = await chattingUser.toJson() authenticatedUser = await chattingUser.toJson()
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'And another nice message to other chatting user', content: 'And another nice message to other chatting user',
@ -331,7 +424,7 @@ describe('Message', () => {
it('returns the messages', async () => { it('returns the messages', async () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
}, },
@ -349,7 +442,7 @@ describe('Message', () => {
avatar: expect.any(String), avatar: expect.any(String),
date: expect.any(String), date: expect.any(String),
saved: true, saved: true,
distributed: true, distributed: false,
seen: false, seen: false,
}), }),
expect.objectContaining({ expect.objectContaining({
@ -384,7 +477,7 @@ describe('Message', () => {
it('returns the messages paginated', async () => { it('returns the messages paginated', async () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
first: 2, first: 2,
@ -419,7 +512,7 @@ describe('Message', () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
first: 2, first: 2,
@ -454,7 +547,7 @@ describe('Message', () => {
it('returns null', async () => { it('returns null', async () => {
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
}, },
@ -479,7 +572,7 @@ describe('Message', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect( await expect(
mutate({ mutate({
mutation: markMessagesAsSeen(), mutation: MarkMessagesAsSeen,
variables: { variables: {
messageIds: ['some-id'], messageIds: ['some-id'],
}, },
@ -492,10 +585,41 @@ describe('Message', () => {
describe('authenticated', () => { describe('authenticated', () => {
const messageIds: string[] = [] 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() authenticatedUser = await otherChattingUser.toJson()
const msgs = await query({ const msgs = await query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
}, },
@ -506,7 +630,7 @@ describe('Message', () => {
it('returns true', async () => { it('returns true', async () => {
await expect( await expect(
mutate({ mutate({
mutation: markMessagesAsSeen(), mutation: MarkMessagesAsSeen,
variables: { variables: {
messageIds, messageIds,
}, },
@ -520,9 +644,15 @@ describe('Message', () => {
}) })
it('has seen prop set to true', async () => { it('has seen prop set to true', async () => {
await mutate({
mutation: MarkMessagesAsSeen,
variables: {
messageIds,
},
})
await expect( await expect(
query({ query({
query: messageQuery(), query: Message,
variables: { variables: {
roomId, roomId,
}, },

View File

@ -7,8 +7,10 @@
import { withFilter } from 'graphql-subscriptions' import { withFilter } from 'graphql-subscriptions'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import CONFIG, { isS3configured } from '@config/index'
import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions' import { CHAT_MESSAGE_ADDED } from '@constants/subscriptions'
import { attachments } from './attachments/attachments'
import Resolver from './helpers/Resolver' import Resolver from './helpers/Resolver'
const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { const setMessagesAsDistributed = async (undistributedMessagesIds, session) => {
@ -70,7 +72,7 @@ export default {
}, },
Mutation: { Mutation: {
CreateMessage: async (_parent, params, context, _resolveInfo) => { CreateMessage: async (_parent, params, context, _resolveInfo) => {
const { roomId, content } = params const { roomId, content, files = [] } = params
const { const {
user: { id: currentUserId }, user: { id: currentUserId },
} = context } = context
@ -116,7 +118,40 @@ export default {
return message return message
}) })
try { 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) { } catch (error) {
throw new Error(error) throw new Error(error)
} finally { } finally {
@ -156,6 +191,9 @@ export default {
author: '<-[:CREATED]-(related:User)', author: '<-[:CREATED]-(related:User)',
room: '-[:INSIDE]->(related:Room)', room: '-[:INSIDE]->(related:Room)',
}, },
hasMany: {
files: '-[:ATTACHMENT]-(related:File)',
},
}), }),
}, },
} }

View File

@ -5,7 +5,7 @@ import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '@db/factories' import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j' 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 { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { roomQuery } from '@graphql/queries/roomQuery' import { roomQuery } from '@graphql/queries/roomQuery'
import { unreadRoomsQuery } from '@graphql/queries/unreadRoomsQuery' import { unreadRoomsQuery } from '@graphql/queries/unreadRoomsQuery'
@ -327,21 +327,21 @@ describe('Room', () => {
}) })
otherRoomId = result.data.CreateRoom.roomId otherRoomId = result.data.CreateRoom.roomId
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: otherRoomId, roomId: otherRoomId,
content: 'Message to not chatting user', content: 'Message to not chatting user',
}, },
}) })
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: '1st message to other chatting user', content: '1st message to other chatting user',
}, },
}) })
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: '2nd message to other chatting user', content: '2nd message to other chatting user',
@ -356,7 +356,7 @@ describe('Room', () => {
}) })
otherRoomId = result2.data.CreateRoom.roomId otherRoomId = result2.data.CreateRoom.roomId
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId: otherRoomId, roomId: otherRoomId,
content: 'Other message to not chatting user', content: 'Other message to not chatting user',

View 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,
}

View File

@ -12,7 +12,7 @@ type Message {
createdAt: String createdAt: String
updatedAt: String updatedAt: String
content: String! content: String
author: User! @relation(name: "CREATED", direction: "IN") author: User! @relation(name: "CREATED", direction: "IN")
room: Room! @relation(name: "INSIDE", direction: "OUT") room: Room! @relation(name: "INSIDE", direction: "OUT")
@ -25,12 +25,14 @@ type Message {
saved: Boolean saved: Boolean
distributed: Boolean distributed: Boolean
seen: Boolean seen: Boolean
files: [File]! @relation(name: "ATTACHMENT", direction: "OUT")
} }
type Mutation { type Mutation {
CreateMessage( CreateMessage(
roomId: ID! roomId: ID!
content: String! content: String
files: [FileInput]
): Message ): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean MarkMessagesAsSeen(messageIds: [String!]): Boolean

View File

@ -13,7 +13,7 @@ import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories' import Factory, { cleanDatabase } from '@db/factories'
import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation' import { changeGroupMemberRoleMutation } from '@graphql/queries/changeGroupMemberRoleMutation'
import { createGroupMutation } from '@graphql/queries/createGroupMutation' 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 { createRoomMutation } from '@graphql/queries/createRoomMutation'
import { joinGroupMutation } from '@graphql/queries/joinGroupMutation' import { joinGroupMutation } from '@graphql/queries/joinGroupMutation'
import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation' import { leaveGroupMutation } from '@graphql/queries/leaveGroupMutation'
@ -918,7 +918,7 @@ describe('notifications', () => {
isUserOnlineMock = jest.fn().mockReturnValue(true) isUserOnlineMock = jest.fn().mockReturnValue(true)
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to chatReceiver', content: 'Some nice message to chatReceiver',
@ -953,7 +953,7 @@ describe('notifications', () => {
isUserOnlineMock = jest.fn().mockReturnValue(false) isUserOnlineMock = jest.fn().mockReturnValue(false)
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to chatReceiver', content: 'Some nice message to chatReceiver',
@ -1002,7 +1002,7 @@ describe('notifications', () => {
await chatReceiver.relateTo(chatSender, 'blocked') await chatReceiver.relateTo(chatSender, 'blocked')
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to chatReceiver', content: 'Some nice message to chatReceiver',
@ -1022,7 +1022,7 @@ describe('notifications', () => {
await chatReceiver.relateTo(chatSender, 'muted') await chatReceiver.relateTo(chatSender, 'muted')
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to chatReceiver', content: 'Some nice message to chatReceiver',
@ -1042,7 +1042,7 @@ describe('notifications', () => {
await chatReceiver.update({ emailNotificationsChatMessage: false }) await chatReceiver.update({ emailNotificationsChatMessage: false })
await mutate({ await mutate({
mutation: createMessageMutation(), mutation: CreateMessage,
variables: { variables: {
roomId, roomId,
content: 'Some nice message to chatReceiver', content: 'Some nice message to chatReceiver',

View File

@ -41,6 +41,7 @@ services:
- AWS_REGION=local - AWS_REGION=local
- AWS_BUCKET=ocelot - AWS_BUCKET=ocelot
- S3_PUBLIC_GATEWAY=http:/localhost:9000 - S3_PUBLIC_GATEWAY=http:/localhost:9000
- DEBUG=neo4j-graphql-js
volumes: volumes:
- ./backend:/app - ./backend:/app

View File

@ -15,8 +15,9 @@
:room-actions="JSON.stringify(roomActions)" :room-actions="JSON.stringify(roomActions)"
:rooms-loaded="roomsLoaded" :rooms-loaded="roomsLoaded"
:loading-rooms="loadingRooms" :loading-rooms="loadingRooms"
show-files="false" show-files="true"
show-audio="false" show-audio="true"
capture-files="true"
:height="'calc(100dvh - 190px)'" :height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)" :styles="JSON.stringify(computedChatStyle)"
:show-footer="true" :show-footer="true"
@ -29,6 +30,7 @@
@add-room="toggleUserSearch" @add-room="toggleUserSearch"
@show-demo-options="showDemoOptions = $event" @show-demo-options="showDemoOptions = $event"
@open-user-tag="redirectToUserProfile($event.detail[0])" @open-user-tag="redirectToUserProfile($event.detail[0])"
@open-file="openFile($event.detail[0].file.file)"
> >
<div <div
v-if="selectedRoom && selectedRoom.roomId" v-if="selectedRoom && selectedRoom.roomId"
@ -356,6 +358,8 @@ export default {
changedRoom.lastMessage = data.chatMessageAdded changedRoom.lastMessage = data.chatMessageAdded
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30) changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30)
changedRoom.lastMessageAt = data.chatMessageAdded.date changedRoom.lastMessageAt = data.chatMessageAdded.date
// Move changed room to the top of the list
changedRoom.index = data.chatMessageAdded.date
changedRoom.unreadCount++ changedRoom.unreadCount++
this.rooms[roomIndex] = changedRoom this.rooms[roomIndex] = changedRoom
if (data.chatMessageAdded.room.id === this.selectedRoom?.id) { 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 { try {
const { const { data } = await this.$apollo.mutate({
data: { CreateMessage: createdMessage },
} = await this.$apollo.mutate({
mutation: createMessageMutation(), mutation: createMessageMutation(),
variables: { variables: mutationVariables,
roomId: message.roomId,
content: message.content,
},
}) })
const roomIndex = this.rooms.findIndex((r) => r.id === message.roomId) const createdMessagePayload = data.CreateMessage
const changedRoom = { ...this.rooms[roomIndex] }
changedRoom.lastMessage = createdMessage if (createdMessagePayload) {
changedRoom.lastMessage.content = changedRoom.lastMessage.content.trim().substring(0, 30) const roomIndex = this.rooms.findIndex((r) => r.id === roomId)
// move current room to top (not 100% working) if (roomIndex !== -1) {
// const rooms = [...this.rooms] const changedRoom = { ...this.rooms[roomIndex] }
// rooms.splice(roomIndex,1) changedRoom.lastMessage.content = createdMessagePayload.content.trim().substring(0, 30)
// this.rooms = [changedRoom, ...rooms] changedRoom.lastMessage.date = createdMessagePayload.date
this.rooms[roomIndex] = changedRoom
// 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) { } catch (error) {
this.$toast.error(error.message) this.$toast.error(error.message)
} }
this.fetchMessages({ this.fetchMessages({
room: this.rooms.find((r) => r.roomId === message.roomId), room: this.rooms.find((r) => r.roomId === messageDetails.roomId),
options: { refetch: true }, options: { refetch: true },
}) })
}, },
@ -414,7 +439,7 @@ export default {
...room.lastMessage, ...room.lastMessage,
content: room.lastMessage?.content?.trim().substring(0, 30), content: room.lastMessage?.content?.trim().substring(0, 30),
} }
: null, : {},
users: room.users.map((u) => { users: room.users.map((u) => {
return { ...u, username: u.name, avatar: this.$filters.proxyApiUrl(u.avatar?.url) } 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 }) { redirectToUserProfile({ user }) {
const userID = user.id const userID = user.id
const userName = user.name.toLowerCase().replaceAll(' ', '-') const userName = user.name.toLowerCase().replaceAll(' ', '-')

View File

@ -2,8 +2,8 @@ import gql from 'graphql-tag'
export const createMessageMutation = () => { export const createMessageMutation = () => {
return gql` return gql`
mutation ($roomId: ID!, $content: String!) { mutation ($roomId: ID!, $content: String, $files: [FileInput]) {
CreateMessage(roomId: $roomId, content: $content) { CreateMessage(roomId: $roomId, content: $content, files: $files) {
#_id #_id
id id
indexId indexId
@ -21,6 +21,13 @@ export const createMessageMutation = () => {
saved saved
distributed distributed
seen seen
files {
url
name
#size
type
#preview
}
} }
} }
` `
@ -47,6 +54,15 @@ export const messageQuery = () => {
saved saved
distributed distributed
seen seen
files {
url
name
#size
type
#audio
#duration
#preview
}
} }
} }
` `
@ -73,6 +89,15 @@ export const chatMessageAdded = () => {
saved saved
distributed distributed
seen seen
files {
url
name
#size
type
#audio
#duration
#preview
}
} }
} }
` `