diff --git a/.changeset/ninety-rivers-mix.md b/.changeset/ninety-rivers-mix.md new file mode 100644 index 00000000000..fbd10b2a04d --- /dev/null +++ b/.changeset/ninety-rivers-mix.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": minor +--- + +Fixed issue with "Export room as file" feature (`rooms.export` endpoint) generating an empty export when given an invalid date diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 9576a79f667..40998201b03 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -2,7 +2,7 @@ import { Media } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; -import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps } from '@rocket.chat/rest-typings'; +import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; @@ -599,15 +599,11 @@ API.v1.addRoute( API.v1.addRoute( 'rooms.export', - { authRequired: true }, + { authRequired: true, validateParams: isRoomsExportProps }, { async post() { const { rid, type } = this.bodyParams; - if (!rid || !type || !['email', 'file'].includes(type)) { - throw new Meteor.Error('error-invalid-params'); - } - if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) { throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); } @@ -627,12 +623,8 @@ API.v1.addRoute( const { dateFrom, dateTo } = this.bodyParams; const { format } = this.bodyParams; - if (!['html', 'json'].includes(format || '')) { - throw new Meteor.Error('error-invalid-format'); - } - - const convertedDateFrom = new Date(dateFrom || ''); - const convertedDateTo = new Date(dateTo || ''); + const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); + const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); convertedDateTo.setDate(convertedDateTo.getDate() + 1); if (convertedDateFrom > convertedDateTo) { @@ -658,10 +650,6 @@ API.v1.addRoute( throw new Meteor.Error('error-invalid-recipient'); } - if (messages?.length === 0) { - throw new Meteor.Error('error-invalid-messages'); - } - const result = await dataExport.sendViaEmail( { rid, diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx b/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx index 3d5bc5966ca..9f52085e7bd 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx @@ -31,12 +31,12 @@ const FileExport = ({ formId, rid, exportOptions, onCancel }: FileExportProps) = [t], ); - const handleExport = ({ type, dateFrom, dateTo, format }: MailExportFormValues) => { + const handleExport = ({ dateFrom, dateTo, format }: MailExportFormValues) => { roomExportMutation.mutateAsync({ rid, - type, - dateFrom, - dateTo, + type: 'file', + ...(dateFrom && { dateFrom }), + ...(dateTo && { dateTo }), format, }); }; diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/MailExportForm.tsx b/apps/meteor/client/views/room/contextualBar/ExportMessages/MailExportForm.tsx index 6a48e4b679b..39e043bb9c0 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/MailExportForm.tsx +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/MailExportForm.tsx @@ -72,10 +72,10 @@ const MailExportForm = ({ formId, rid, onCancel, exportOptions }: MailExportForm setValue('messagesCount', messages.length); }, [setValue, messages.length]); - const handleExport = async ({ type, toUsers, subject, additionalEmails }: MailExportFormValues) => { + const handleExport = async ({ toUsers, subject, additionalEmails }: MailExportFormValues) => { roomExportMutation.mutateAsync({ rid, - type, + type: 'email', toUsers, toEmails: additionalEmails?.split(','), subject, diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 8104d0ed83f..1faa7538d14 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -56,13 +56,13 @@ const getAttachmentData = (attachment: MessageAttachment, message: IMessage) => }; }; -type MessageData = Pick & { +export type MessageData = Pick & { username?: IUser['username'] | IUser['name']; attachments?: ReturnType[]; type?: IMessage['t']; }; -const getMessageData = ( +export const getMessageData = ( msg: IMessage, hideUsers: boolean, userData: Pick | undefined, @@ -160,7 +160,7 @@ const getMessageData = ( return messageObject; }; -const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => { +export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => { if (type === 'json') { return JSON.stringify(messageObject); } @@ -192,7 +192,7 @@ const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, return file.join('\n'); }; -const exportRoomMessages = async ( +export const exportRoomMessages = async ( rid: IRoom['_id'], exportType: 'json' | 'html', skip: number, diff --git a/apps/meteor/tests/end-to-end/api/09-rooms.js b/apps/meteor/tests/end-to-end/api/09-rooms.js index b9b8f71036a..dc8a6a20930 100644 --- a/apps/meteor/tests/end-to-end/api/09-rooms.js +++ b/apps/meteor/tests/end-to-end/api/09-rooms.js @@ -2140,4 +2140,302 @@ describe('[Rooms]', function () { }); }); }); + + describe('/rooms.export', () => { + let testChannel; + let testMessageId; + + before(async () => { + const result = await createRoom({ type: 'c', name: `channel.export.test.${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + const { body: { message } = {} } = await sendSimpleMessage({ + roomId: testChannel._id, + text: 'Message to create thread', + }); + testMessageId = message._id; + }); + + after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + + it('should fail exporting room as file if dates are incorrectly provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: 'test-date', + dateTo: 'test-date', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if no roomId is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'rid'"); + }); + }); + + it('should fail exporting room as file if no type is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error').include("must have required property 'type'"); + }); + }); + + it('should fail exporting room as file if fromDate is after toDate (incorrect date interval)', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-22', + dateTo: '2024-03-15', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-dates'); + expect(res.body).to.have.property('error', 'From date cannot be after To date [error-invalid-dates]'); + }); + }); + + it('should fail exporting room as file if invalid roomId is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: 'invalid-rid', + type: 'file', + dateFrom: '2024-03-22', + dateTo: '2024-03-15', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + }); + }); + + it('should fail exporting room as file if no format is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if an invalid format is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'invalid-format', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should fail exporting room as file if an invalid type is provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'invalid-type', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should succesfully export room as file', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + dateFrom: '2024-03-15', + dateTo: '2024-03-22', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should succesfully export room as file even if no dates are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'file', + format: 'html', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail exporting room via email if target users AND target emails are NOT provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no target e-mails are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toEmails: [], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no target users or e-mails params are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-recipient'); + }); + }); + + it('should fail exporting room via email if no messages are provided', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [credentials['X-User-Id']], + subject: 'Test Subject', + messages: [], + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + + it('should succesfully export room via email', async () => { + return request + .post(api('rooms.export')) + .set(credentials) + .send({ + rid: testChannel._id, + type: 'email', + toUsers: [credentials['X-User-Id']], + subject: 'Test Subject', + messages: [testMessageId], + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('missing'); + expect(res.body.missing).to.be.an('array').that.is.empty; + }); + }); + }); }); diff --git a/apps/meteor/tests/mocks/data.ts b/apps/meteor/tests/mocks/data.ts index 812de9579b4..5e48d89f719 100644 --- a/apps/meteor/tests/mocks/data.ts +++ b/apps/meteor/tests/mocks/data.ts @@ -228,3 +228,30 @@ export const createFakeLicenseInfo = (partial: Partial(overrides?: Partial): TMessage; +export function createFakeMessageWithAttachment(overrides?: Partial): IMessage { + const fakeMessage = createFakeMessage(overrides); + const fileId = faker.database.mongodbObjectId(); + const fileName = faker.system.commonFileName('txt'); + + return { + ...fakeMessage, + msg: '', + file: { + _id: fileId, + name: fileName, + type: 'text/plain', + size: faker.number.int(), + format: faker.string.alpha(), + }, + attachments: [ + { + type: 'file', + title: fileName, + title_link: `/file-upload/${fileId}/${fileName}`, + }, + ], + ...overrides, + }; +} diff --git a/apps/meteor/tests/unit/app/apps/server/mocks/data/messages.data.js b/apps/meteor/tests/unit/app/apps/server/mocks/data/messages.data.js index cc108b8374e..70d1505d97f 100644 --- a/apps/meteor/tests/unit/app/apps/server/mocks/data/messages.data.js +++ b/apps/meteor/tests/unit/app/apps/server/mocks/data/messages.data.js @@ -1,3 +1,7 @@ +import { faker } from '@faker-js/faker'; + +import { createFakeMessage, createFakeMessageWithAttachment } from '../../../../../../mocks/data'; + export const appMessageMock = { id: 'appMessageMock', text: 'rocket.cat', @@ -105,3 +109,20 @@ export const appMessageInvalidRoomMock = { t: 'uj', }, }; + +const testUsername = faker.internet.userName(); +const testUserId = faker.database.mongodbObjectId(); +export const exportMessagesMock = [ + createFakeMessage({ t: 'uj', u: { _id: testUserId, username: testUsername }, msg: testUsername }), + createFakeMessageWithAttachment(), + createFakeMessageWithAttachment({ + attachments: [ + { + type: 'file', + title_link: '/file-upload/txt-file-id/test.txt', + }, + ], + }), + createFakeMessage(), + createFakeMessage({ t: 'ujt', u: { _id: testUserId, username: testUsername }, msg: testUsername }), +]; diff --git a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts new file mode 100644 index 00000000000..6fb15a68028 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts @@ -0,0 +1,158 @@ +import { faker } from '@faker-js/faker'; +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { MessageData } from '../../../../../server/lib/dataExport/exportRoomMessagesToFile'; +import { exportMessagesMock } from '../../../app/apps/server/mocks/data/messages.data'; + +// Create stubs for dependencies +const stubs = { + findPaginatedMessages: sinon.stub(), + mkdir: sinon.stub(), + writeFile: sinon.stub(), + findPaginatedMessagesCursor: sinon.stub(), + findPaginatedMessagesTotal: sinon.stub(), + translateKey: sinon.stub(), + settings: sinon.stub(), +}; + +const { getMessageData, exportRoomMessages, exportMessageObject } = proxyquire + .noCallThru() + .load('../../../../../server/lib/dataExport/exportRoomMessagesToFile.ts', { + '@rocket.chat/models': { + Messages: { + findPaginated: stubs.findPaginatedMessages, + }, + }, + 'fs/promises': { + mkdir: stubs.mkdir, + writeFile: stubs.writeFile, + }, + '../i18n': { + i18n: { + t: stubs.translateKey, + }, + }, + '../../../app/settings/server': { + settings: stubs.settings, + }, + }); + +describe('Export - exportMessageObject', () => { + let messagesData: MessageData[]; + const translationPlaceholder = 'translation-placeholder'; + before(() => { + stubs.translateKey.returns(translationPlaceholder); + messagesData = exportMessagesMock.map((message) => getMessageData(message, false)); + }); + + it('should only stringify message object when exporting message as json', async () => { + const result = await exportMessageObject('json', messagesData[3]); + + expect(result).to.be.a.string; + expect(result).to.equal(JSON.stringify(messagesData[3])); + }); + + it('should correctly add tags when exporting plain text message object as html', async () => { + const result = await exportMessageObject('html', messagesData[3]); + + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[3].username} (${new Date(messagesData[3].ts).toUTCString()}):
\n${messagesData[3].msg}\n

`, + ); + }); + + it('should correctly format system messages when exporting message object as html', async () => { + const result = await exportMessageObject('html', messagesData[0]); + + expect(messagesData[0].msg).to.equal(translationPlaceholder); + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[0].username} (${new Date(messagesData[0].ts).toUTCString()}):
\n${ + messagesData[0].msg + }\n

`, + ); + }); + + it('should correctly format non italic system messages when exporting message object as html', async () => { + const result = await exportMessageObject('html', messagesData[4]); + + expect(messagesData[4].msg).to.equal(translationPlaceholder); + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[4].username} (${new Date(messagesData[4].ts).toUTCString()}):
\n${messagesData[4].msg}\n

`, + ); + }); + + it('should correctly reference file when exporting a message object with an attachment as html', async () => { + const result = await exportMessageObject('html', messagesData[1], exportMessagesMock[1].file); + + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[1].username} (${new Date(messagesData[1].ts).toUTCString()}):
\n${ + messagesData[1].msg + }\n
${ + messagesData[1].attachments?.[0].title + }\n

`, + ); + }); + + it('should use fallback attachment description when no title is provided on message object export as html', async () => { + const result = await exportMessageObject('html', messagesData[2], exportMessagesMock[2].file); + + expect(stubs.translateKey.calledWith('Message_Attachments')).to.be.true; + expect(result).to.be.a.string; + expect(result).to.equal( + `

${messagesData[2].username} (${new Date(messagesData[2].ts).toUTCString()}):
\n${ + exportMessagesMock[1].msg + }\n
${translationPlaceholder}\n

`, + ); + }); +}); + +describe('Export - exportRoomMessages', () => { + const totalMessages = 10; + const userData = { + _id: faker.database.mongodbObjectId(), + name: faker.person.fullName(), + username: faker.internet.userName(), + }; + + before(() => { + stubs.findPaginatedMessagesCursor.resolves(exportMessagesMock); + stubs.findPaginatedMessagesTotal.resolves(totalMessages); + stubs.findPaginatedMessages.returns({ + cursor: { toArray: stubs.findPaginatedMessagesCursor }, + totalCount: stubs.findPaginatedMessagesTotal(), + }); + stubs.translateKey.returns('translated-placeholder-uj'); + }); + + it('should correctly export multiple messages to result when exporting room as json', async () => { + const result = await exportRoomMessages('test-rid', 'json', 0, 100, userData); + + expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true; + expect(result).to.be.an('object'); + expect(result).to.have.property('total', totalMessages); + expect(result).to.have.property('exported', exportMessagesMock.length); + expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length); + const messagesWithFiles = exportMessagesMock.filter((message) => message.file); + expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length); + }); + + it('should correctly export multiple messages to result when exporting room as html', async () => { + const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData); + + expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true; + expect(result).to.be.an('object'); + expect(result).to.have.property('total', totalMessages); + expect(result).to.have.property('exported', exportMessagesMock.length); + expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length); + const messagesWithFiles = exportMessagesMock.filter((message) => message.file); + expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length); + }); +}); diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index bb97d37b90d..da68daf2505 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,12 +1,8 @@ import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload } from '@rocket.chat/core-typings'; -import Ajv from 'ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; - -const ajv = new Ajv({ - coerceTypes: true, -}); +import { ajv } from './Ajv'; type RoomsAutoCompleteChannelAndPrivateProps = { selector: string }; @@ -169,73 +165,100 @@ const RoomsCreateDiscussionSchema = { export const isRoomsCreateDiscussionProps = ajv.compile(RoomsCreateDiscussionSchema); -type RoomsExportProps = { +type RoomsExportProps = RoomsExportFileProps | RoomsExportEmailProps; + +type RoomsExportFileProps = { rid: IRoom['_id']; - type: 'email' | 'file'; + type: 'file'; + format: 'html' | 'json'; + dateFrom?: string; + dateTo?: string; +}; + +type RoomsExportEmailProps = { + rid: IRoom['_id']; + type: 'email'; toUsers?: IUser['username'][]; toEmails?: string[]; additionalEmails?: string; subject?: string; - messages?: IMessage['_id'][]; - dateFrom?: string; - dateTo?: string; - format?: 'html' | 'json'; + messages: IMessage['_id'][]; }; const RoomsExportSchema = { - type: 'object', - properties: { - rid: { - type: 'string', - }, - type: { - type: 'string', - nullable: true, - }, - toUsers: { - type: 'array', - items: { - type: 'string', - }, - nullable: true, - }, - toEmails: { - type: 'array', - items: { - type: 'string', + oneOf: [ + { + type: 'object', + properties: { + rid: { + type: 'string', + }, + type: { + type: 'string', + enum: ['file'], + }, + format: { + type: 'string', + enum: ['html', 'json'], + }, + dateFrom: { + type: 'string', + nullable: true, + format: 'date', + }, + dateTo: { + type: 'string', + nullable: true, + format: 'date', + }, }, - nullable: true, - }, - additionalEmails: { - type: 'string', - nullable: true, - }, - subject: { - type: 'string', - nullable: true, + required: ['rid', 'type', 'format'], + additionalProperties: false, }, - messages: { - type: 'array', - items: { - type: 'string', + { + type: 'object', + properties: { + rid: { + type: 'string', + }, + type: { + type: 'string', + enum: ['email'], + }, + toUsers: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + toEmails: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + additionalEmails: { + type: 'string', + nullable: true, + }, + subject: { + type: 'string', + nullable: true, + }, + messages: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, + }, }, - nullable: true, - }, - dateFrom: { - type: 'string', - nullable: true, - }, - dateTo: { - type: 'string', - nullable: true, - }, - format: { - type: 'string', - nullable: true, + required: ['rid', 'type', 'messages'], + additionalProperties: false, }, - }, - required: ['rid'], - additionalProperties: false, + ], }; export const isRoomsExportProps = ajv.compile(RoomsExportSchema);