mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +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)
|
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({
|
||||||
|
|||||||
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 },
|
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() },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
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()
|
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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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-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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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,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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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(' ', '-')
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user