diff --git a/.changeset/chatty-papayas-provide.md b/.changeset/chatty-papayas-provide.md new file mode 100644 index 00000000..1b65da10 --- /dev/null +++ b/.changeset/chatty-papayas-provide.md @@ -0,0 +1,5 @@ +--- +'@nordeck/matrix-meetings-bot': minor +--- + +Bot sends a message when a single meeting is edited. diff --git a/e2e/src/openXchange.spec.ts b/e2e/src/openXchange.spec.ts index dd492585..a136cef7 100644 --- a/e2e/src/openXchange.spec.ts +++ b/e2e/src/openXchange.spec.ts @@ -149,7 +149,12 @@ test.describe('OpenXchange', () => { await expect( aliceElementWebPage.locateChatMessageInRoom( - /10\/04\/2040 10:30 AM CEST to 10\/04\/2040 11:00 AM CEST/, + /October 4, 2040, 10:30 – 11:00 AM GMT\+2/, + ), + ).toBeVisible(); + await expect( + aliceElementWebPage.locateChatMessageInRoom( + /\(previously: October 3, 2040, 10:30 – 11:00 AM GMT\+2\)/, ), ).toBeVisible(); diff --git a/e2e/src/recurringMeetings.spec.ts b/e2e/src/recurringMeetings.spec.ts index 0ed6c3ce..32ec578b 100644 --- a/e2e/src/recurringMeetings.spec.ts +++ b/e2e/src/recurringMeetings.spec.ts @@ -228,6 +228,20 @@ test.describe('Recurring Meetings', () => { aliceMeetingsWidgetPage.getMeeting('My Meeting', '10/04/2040') .meetingTimeRangeText, ).toHaveText('10:30 AM – 11:30 AM. Recurrence: Every day for 5 times'); + + await aliceElementWebPage.switchToRoom('My Meeting'); + + await expect( + aliceElementWebPage.locateChatMessageInRoom( + /A single meeting from a meeting series is moved to October 9, 2040, 10:40 – 11:40 AM GMT\+2/, + ), + ).toBeVisible(); + + await expect( + aliceElementWebPage.locateChatMessageInRoom( + /\(previously: October 3, 2040, 10:30 – 11:30 AM GMT\+2/, + ), + ).toBeVisible(); }); test('should covert a recurring meeting into a single meeting', async ({ @@ -372,6 +386,7 @@ test.describe('Recurring Meetings', () => { }); test('should delete a single recurring meeting', async ({ + aliceElementWebPage, aliceMeetingsWidgetPage, }) => { await aliceMeetingsWidgetPage.setDateFilter([2040, 10, 1], [2040, 10, 9]); @@ -404,6 +419,14 @@ test.describe('Recurring Meetings', () => { await expect( aliceMeetingsWidgetPage.getMeeting('My Meeting').card, ).toHaveCount(4); + + await aliceElementWebPage.switchToRoom('My Meeting'); + + await expect( + aliceElementWebPage.locateChatMessageInRoom( + /A single meeting from a meeting series on October 3, 2040, 10:30 – 11:30 AM GMT\+2 is deleted/, + ), + ).toBeVisible(); }); // TODO: Delete starting from diff --git a/matrix-meetings-bot/package.json b/matrix-meetings-bot/package.json index a44a4c72..fc212512 100644 --- a/matrix-meetings-bot/package.json +++ b/matrix-meetings-bot/package.json @@ -51,7 +51,6 @@ "luxon": "^3.3.0", "matrix-bot-sdk": "^0.6.6", "mime-types": "^2.1.35", - "moment-timezone": "^0.5.43", "mustache": "^4.2.0", "nestjs-pino": "^3.5.0", "node-fetch": "^2.7.0", diff --git a/matrix-meetings-bot/setupTests.ts b/matrix-meetings-bot/setupTests.ts index 6112e33d..37dbfdae 100644 --- a/matrix-meetings-bot/setupTests.ts +++ b/matrix-meetings-bot/setupTests.ts @@ -18,6 +18,7 @@ import i18next from 'i18next'; import i18nextBackend from 'i18next-fs-backend'; import { Settings } from 'luxon'; import 'reflect-metadata'; +import { registerDateRangeFormatter } from './src/dateRangeFormatter'; import translationDe from './src/static/locales/de/translation.json'; import translationEn from './src/static/locales/en/translation.json'; @@ -36,6 +37,8 @@ i18next.use(i18nextBackend).init({ }, }); +registerDateRangeFormatter(i18next); + // We want our tests to be in a reproducible time zone, always resulting in // the same results, independent from where they are run. Settings.defaultZone = 'UTC'; diff --git a/matrix-meetings-bot/src/app.module.ts b/matrix-meetings-bot/src/app.module.ts index 571e7943..66be09b8 100644 --- a/matrix-meetings-bot/src/app.module.ts +++ b/matrix-meetings-bot/src/app.module.ts @@ -22,7 +22,7 @@ import { NestModule, } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import i18next, { TFunction } from 'i18next'; +import i18next from 'i18next'; import i18nextFsBackend from 'i18next-fs-backend'; import i18nextMiddleware from 'i18next-http-middleware'; import { @@ -49,6 +49,7 @@ import { HealthCheckController } from './controller/HealthCheckController'; import { MeetingController } from './controller/MeetingController'; import { WelcomeWorkflowController } from './controller/WelcomeWorkflowController'; import { WidgetController } from './controller/WidgetController'; +import { registerDateRangeFormatter } from './dateRangeFormatter'; import { RoomMatrixEventsReader } from './io/RoomMatrixEventsReader'; import { WidgetLayoutConfigReader } from './io/WidgetLayoutConfigReader'; import { MatrixClientProxyHandler } from './matrix/MatrixClientProxyHandler'; @@ -170,12 +171,10 @@ const appRuntimeContextFactory: FactoryProvider> = { inject: [MatrixClient], }; -const i18nFactory: FactoryProvider> = { +const i18nFactory: FactoryProvider = { provide: ModuleProviderToken.I18N, - useFactory: async ( - appRuntimeContext: AppRuntimeContext, - ): Promise => { - return i18next + useFactory: (appRuntimeContext: AppRuntimeContext): void => { + i18next .use(i18nextFsBackend) .use(i18nextMiddleware.LanguageDetector) .init({ @@ -191,6 +190,7 @@ const i18nFactory: FactoryProvider> = { }, preload: appRuntimeContext.supportedLngs, }); + registerDateRangeFormatter(i18next); }, inject: [AppRuntimeContext], }; diff --git a/matrix-meetings-bot/src/client/MeetingClient.test.ts b/matrix-meetings-bot/src/client/MeetingClient.test.ts index 26077805..e46d643a 100644 --- a/matrix-meetings-bot/src/client/MeetingClient.test.ts +++ b/matrix-meetings-bot/src/client/MeetingClient.test.ts @@ -198,9 +198,19 @@ describe('MeetingClient', () => { type: 'net.nordeck.meetings.metadata', content: { creator: '@user-id:example', - start_time: '2020-05-07T10:00:00+02:00', - end_time: '2020-05-07T11:00:00+02:00', - auto_deletion_offset: 60, + calendar: [ + { + uid: expect.any(String), + dtstart: { + tzid: 'UTC', + value: '20200507T080000', + }, + dtend: { tzid: 'UTC', value: '20200507T090000' }, + }, + ], + force_deletion_at: new Date( + '2020-05-07T12:00:00+02:00', + ).getTime(), }, }, ], diff --git a/matrix-meetings-bot/src/client/MeetingClient.ts b/matrix-meetings-bot/src/client/MeetingClient.ts index 18ec1cdb..65b9cabe 100644 --- a/matrix-meetings-bot/src/client/MeetingClient.ts +++ b/matrix-meetings-bot/src/client/MeetingClient.ts @@ -47,11 +47,7 @@ import { MeetingType } from '../model/MeetingType'; import { Room } from '../model/Room'; import { RoomEventName } from '../model/RoomEventName'; import { StateEventName } from '../model/StateEventName'; -import { - getForceDeletionTime, - getMeetingEndTime, - getMeetingStartTime, -} from '../shared'; +import { getForceDeletionTime } from '../shared'; import { templateHelper } from '../util/TemplateHelper'; import { extractOxRrule } from '../util/extractOxRrule'; import { migrateMeetingTime } from '../util/migrateMeetingTime'; @@ -180,14 +176,12 @@ export class MeetingClient { userContext.userId, ); - const meetingStartTime = getMeetingStartTime( - meetingCreate.start_time, - meetingCreate.calendar, - ); - - const meetingEndTime = getMeetingEndTime( - meetingCreate.end_time, - meetingCreate.calendar, + // change data model if meeting is in old format + const externalRrule = extractOxRrule(meetingCreate); + const calendar = migrateMeetingTime( + meetingCreate, + externalRrule, + undefined, ); const memberEventsWithReason: DeepReadonlyArray< @@ -198,9 +192,7 @@ export class MeetingClient { const { textReason, htmlReason } = templateHelper.makeInviteReasons( { description: meetingCreate.description, - startTime: meetingStartTime, - endTime: meetingEndTime, - calendar: meetingCreate.calendar, + calendar, }, userContext, se.state_key === userContext.userId ? undefined : displayname, @@ -241,19 +233,9 @@ export class MeetingClient { params, ); - // change data model if meeting is OX with non-empty rrules - const { start_time, end_time, calendar } = migrateMeetingTime( - meetingCreate, - extractOxRrule(meetingCreate), - ); - const meetingsMetadataEventContent: IMeetingsMetadataEventContent = { - start_time, - end_time, calendar, force_deletion_at: getForceDeletionTime(autoDeletionOffset, calendar), - auto_deletion_offset: - calendar === undefined ? autoDeletionOffset : undefined, creator: userContext.userId, external_data: meetingCreate.external_data, }; diff --git a/matrix-meetings-bot/src/dateFormat.ts b/matrix-meetings-bot/src/dateFormat.ts new file mode 100644 index 00000000..3be790b0 --- /dev/null +++ b/matrix-meetings-bot/src/dateFormat.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const fullLongDateFormat = { + hour: 'numeric', + minute: 'numeric', + month: 'long', + year: 'numeric', + day: 'numeric', + timeZoneName: 'short', +}; + +export const fullNumericDateFormat = { + hour: 'numeric', + minute: 'numeric', + month: 'numeric', + year: 'numeric', + day: 'numeric', + timeZoneName: 'short', +}; diff --git a/matrix-meetings-bot/src/dateRangeFormatter.ts b/matrix-meetings-bot/src/dateRangeFormatter.ts new file mode 100644 index 00000000..1b69a572 --- /dev/null +++ b/matrix-meetings-bot/src/dateRangeFormatter.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { i18n } from 'i18next'; + +export function registerDateRangeFormatter(i18n: i18n) { + i18n.services.formatter?.add( + 'daterange', + (value: Date[], lng: string | undefined, options) => { + const [start, end] = value; + + const formatter = new Intl.DateTimeFormat(lng, { ...options }); + // @ts-ignore: DateTimeFormat#formatRange will be available in TypeScript >4.7.2 + return formatter.formatRange(start, end); + }, + ); +} diff --git a/matrix-meetings-bot/src/model/IMeeting.ts b/matrix-meetings-bot/src/model/IMeeting.ts index d5176271..aaa781a7 100644 --- a/matrix-meetings-bot/src/model/IMeeting.ts +++ b/matrix-meetings-bot/src/model/IMeeting.ts @@ -24,9 +24,7 @@ export interface IMeeting { roomId: string; title: string; description: string; - startTime: string; - endTime: string; - calendar?: CalendarEntryDto[]; + calendar: CalendarEntryDto[]; widgetIds: string[]; participants: string[]; autoDeletionOffset?: number; diff --git a/matrix-meetings-bot/src/model/Room.ts b/matrix-meetings-bot/src/model/Room.ts index f3afc5cf..7952c1d9 100644 --- a/matrix-meetings-bot/src/model/Room.ts +++ b/matrix-meetings-bot/src/model/Room.ts @@ -27,7 +27,7 @@ import { import { ICreationContent } from '../matrix/dto/ICreationContent'; import { EncryptionEventContent } from '../matrix/event/EncryptionEventContent'; import { IStateEvent } from '../matrix/event/IStateEvent'; -import { getMeetingEndTime, getMeetingStartTime } from '../shared'; +import { migrateMeetingTime } from '../util/migrateMeetingTime'; import { IMeeting } from './IMeeting'; import { IMeetingsMetadataEventContent } from './IMeetingsMetadataEventContent'; import { IRoom } from './IRoom'; @@ -92,9 +92,12 @@ export class Room implements IRoom { roomId: this._room_id, creator: content.creator, parentRoomId: spaceParent?.state_key, - startTime: getMeetingStartTime(content.start_time, content.calendar), - endTime: getMeetingEndTime(content.end_time, content.calendar), - calendar: content.calendar, + calendar: + content.calendar ?? + migrateMeetingTime({ + start_time: content.start_time, + end_time: content.end_time, + }), autoDeletionOffset: content.auto_deletion_offset, title: ( this.roomEventsByName(StateEventName.M_ROOM_NAME_EVENT)[0] diff --git a/matrix-meetings-bot/src/service/MeetingService.ts b/matrix-meetings-bot/src/service/MeetingService.ts index 5fbfb530..219b8da0 100644 --- a/matrix-meetings-bot/src/service/MeetingService.ts +++ b/matrix-meetings-bot/src/service/MeetingService.ts @@ -61,11 +61,7 @@ import { powerLevelHelper } from '../model/PowerLevelHelper'; import { RoomEventName } from '../model/RoomEventName'; import { StateEventName } from '../model/StateEventName'; import { WidgetType } from '../model/WidgetType'; -import { - getForceDeletionTime, - getMeetingEndTime, - getMeetingStartTime, -} from '../shared'; +import { getForceDeletionTime } from '../shared'; import { IMeetingChanges, meetingChangesHelper } from '../util/IMeetingChanges'; import { templateHelper } from '../util/TemplateHelper'; import { extractOxRrule } from '../util/extractOxRrule'; @@ -366,21 +362,15 @@ export class MeetingService { const oldMeeting = room.meeting; const newMeeting: IMeeting = { ...room.meeting }; - // change data model if meeting is OX with non-empty rrules - const { start_time, end_time, calendar } = migrateMeetingTime( + // change data model if meeting is in old format + const calendar = migrateMeetingTime( meetingDetails, extractOxRrule(meetingDetails), + oldMeeting.calendar, ); - newMeeting.calendar = - start_time === undefined && end_time === undefined - ? calendar ?? newMeeting.calendar - : undefined; + newMeeting.calendar = calendar; - newMeeting.startTime = - getMeetingStartTime(start_time, calendar) ?? newMeeting.startTime; - newMeeting.endTime = - getMeetingEndTime(end_time, calendar) ?? newMeeting.endTime; newMeeting.title = meetingDetails.title ?? newMeeting.title; newMeeting.description = meetingDetails.description ?? newMeeting.description; @@ -392,20 +382,9 @@ export class MeetingService { newMeeting, ); - // The newMeeting.creator is the sender... the room.creator is often the bot. therefore remember the sender as creator - // ==> creator is not necessarily identical to the event's creator field. If the content event gets updated by another moderator it should still represent the original creator of the newMeeting. const content: IMeetingsMetadataEventContent = { creator: newMeeting.creator, - start_time: calendar === undefined ? newMeeting.startTime : undefined, - end_time: calendar === undefined ? newMeeting.endTime : undefined, - calendar: - start_time === undefined && end_time === undefined - ? newMeeting.calendar - : undefined, - auto_deletion_offset: - newMeeting.calendar === undefined - ? room.meeting.autoDeletionOffset - : undefined, + calendar: newMeeting.calendar, force_deletion_at: getForceDeletionTime( this.appConfig.auto_deletion_offset, newMeeting.calendar, @@ -495,13 +474,7 @@ export class MeetingService { .roomMemberEvents() .filter((me) => me.content.membership === 'invite'); - if ( - (meetingChanges.titleChanged || - meetingChanges.descriptionChanged || - meetingChanges.timeChanged || - meetingChanges.calendarChanged) && - room.meeting - ) { + if (meetingChanges.anythingChanged && room.meeting) { const meetingCreator = room.meeting.creator; const { displayname } = await this.matrixClient.getUserProfile(meetingCreator); @@ -511,8 +484,6 @@ export class MeetingService { const { textReason, htmlReason } = templateHelper.makeInviteReasons( { description: newMeeting.description, - startTime: newMeeting.startTime, - endTime: newMeeting.endTime, calendar: newMeeting.calendar, }, userContext, diff --git a/matrix-meetings-bot/src/service/RoomMessageService.ts b/matrix-meetings-bot/src/service/RoomMessageService.ts index c3d8cf00..3398792a 100644 --- a/matrix-meetings-bot/src/service/RoomMessageService.ts +++ b/matrix-meetings-bot/src/service/RoomMessageService.ts @@ -17,14 +17,14 @@ import { Injectable, Logger } from '@nestjs/common'; import i18next from 'i18next'; import { MatrixClient } from 'matrix-bot-sdk'; -import moment from 'moment-timezone'; import { MatrixEndpoint } from '../MatrixEndpoint'; import { MeetingClient } from '../client/MeetingClient'; +import { fullLongDateFormat } from '../dateFormat'; import { ISyncParams } from '../matrix/dto/ISyncParams'; import { IMeeting } from '../model/IMeeting'; import { IUserContext } from '../model/IUserContext'; import { StateEventName } from '../model/StateEventName'; -import { isRecurringCalendarSourceEntry } from '../shared/calendarUtils/helpers'; +import { parseICalDate } from '../shared'; import { formatRRuleText } from '../shared/format'; import { IMeetingChanges } from '../util/IMeetingChanges'; @@ -185,188 +185,245 @@ export class RoomMessageService { return privateRoomIds; } - private static formatDateTime( - timezone: string, - locale: string, - date: string | number | Date, - ): string { - return moment(new Date(date)).tz(timezone).locale(locale).format('L LT z'); - } + public async notifyMeetingTimeChangedAsync( + userContext: IUserContext, + oldMeeting: IMeeting, + newMeeting: IMeeting, + meetingChanges: IMeetingChanges, + toRoomId: string, + ): Promise { + const lng = userContext.locale ? userContext.locale : 'en'; + const timeZone = userContext.timezone ? userContext.timezone : 'UTC'; - private formatHeader(text: string) { - return `${text}
`; - } - private formatCurrent(text: string) { - return `${text}
`; - } - private formatPrevious(text: string) { - return `${text}
`; - } + if (!meetingChanges.anythingChanged) return; - private createTitleLine(lng: string, title: string, previous?: boolean) { - if (previous) { - return this.formatPrevious( - i18next.t( - 'meeting.room.notification.changed.title.previous', - '(previously: {{title}})', - { lng, title }, - ), + let notification = ''; + + if ( + !meetingChanges.calendarChanges.some( + (e) => + e.changeType !== 'updateSingleOrRecurringTime' && + e.changeType !== 'updateSingleOrRecurringRrule', + ) + ) { + notification += formatCurrent( + i18next.t('meeting.room.notification.changed.headLine', 'CHANGES', { + lng, + }), ); - } else { - return this.formatCurrent( + } + + if (meetingChanges.titleChanged) { + notification += formatCurrent( i18next.t( 'meeting.room.notification.changed.title.current', 'Title: {{title}}', - { lng, title }, + { lng, title: newMeeting.title }, ), ); - } - } - private createStartEndTimesLine( - lng: string, - start: string, - end: string, - previous?: boolean, - ) { - if (previous) { - return this.formatPrevious( - i18next.t( - 'meeting.room.notification.changed.date.previous', - '(previously: {{start}} to {{end}})', - { lng, start, end }, - ), - ); - } else { - return this.formatCurrent( + notification += formatPrevious( i18next.t( - 'meeting.room.notification.changed.date.current', - 'Date: {{start}} to {{end}}', - { lng, start, end }, + 'meeting.room.notification.changed.title.previous', + '(previously: {{title}})', + { lng, title: oldMeeting.title }, ), ); } - } - private createDescriptionLine( - lng: string, - description: string, - previous?: boolean, - ) { - if (previous) { - return this.formatPrevious( - i18next.t( - 'meeting.room.notification.changed.description.previous', - '(previously: {{description}})', - { lng, description }, - ), - ); - } else { - return this.formatCurrent( - i18next.t( - 'meeting.room.notification.changed.description.current', - 'Description: {{description}}', - { lng, description }, - ), - ); + + for (const change of meetingChanges.calendarChanges) { + if (change.changeType === 'updateSingleOrRecurringTime') { + notification += formatCurrent( + i18next.t( + 'meeting.room.notification.changed.date.current', + 'Date: {{range, daterange}}', + { + lng, + range: [ + parseICalDate(change.newValue.dtstart).toJSDate(), + parseICalDate(change.newValue.dtend).toJSDate(), + ], + formatParams: { + range: { + timeZone, + ...fullLongDateFormat, + }, + }, + }, + ), + ); + + notification += formatPrevious( + i18next.t( + 'meeting.room.notification.changed.date.previous', + '(previously: {{range, daterange}})', + { + lng, + range: [ + parseICalDate(change.oldValue.dtstart).toJSDate(), + parseICalDate(change.oldValue.dtend).toJSDate(), + ], + formatParams: { + range: { + timeZone, + ...fullLongDateFormat, + }, + }, + }, + ), + ); + } } - } - private createRepetitionLine( - lng: string, - repetitionText: string, - previous?: boolean, - ) { - if (previous) { - return this.formatPrevious( + if (meetingChanges.descriptionChanged) { + notification += formatCurrent( i18next.t( - 'meeting.room.notification.changed.repetition.previous', - '(previously: {{repetitionText}})', - { lng, repetitionText }, + 'meeting.room.notification.changed.description.current', + 'Description: {{description}}', + { lng, description: newMeeting.description }, ), ); - } else { - return this.formatCurrent( + notification += formatPrevious( i18next.t( - 'meeting.room.notification.changed.repetition.current', - 'Repeat meeting: {{repetitionText}}', - { lng, repetitionText }, + 'meeting.room.notification.changed.description.previous', + '(previously: {{description}})', + { lng, description: oldMeeting.description }, ), ); } - } - public async notifyMeetingTimeChangedAsync( - userContext: IUserContext, - oldMeeting: IMeeting, - newMeeting: IMeeting, - meetingChanges: IMeetingChanges, - toRoomId: string, - ): Promise { - const lng = userContext.locale ? userContext.locale : 'en'; - const timeZone = userContext.timezone ? userContext.timezone : 'UTC'; + for (const change of meetingChanges.calendarChanges) { + if (change.changeType === 'updateSingleOrRecurringRrule') { + const newRrule = change.newValue; + const newRruleText = newRrule + ? formatRRuleText(newRrule, i18next.t, lng) + : i18next.t( + 'meeting.room.notification.noRepetition', + 'No repetition', + { + lng, + }, + ); + notification += formatCurrent( + i18next.t( + 'meeting.room.notification.changed.repetition.current', + 'Repeat meeting: {{repetitionText}}', + { lng, repetitionText: newRruleText }, + ), + ); - if (!meetingChanges.anythingChanged) return; + const oldRrule = change.oldValue; + const oldRruleText = oldRrule + ? formatRRuleText(oldRrule, i18next.t, lng) + : i18next.t( + 'meeting.room.notification.noRepetition', + 'No repetition', + { + lng, + }, + ); + notification += formatPrevious( + i18next.t( + 'meeting.room.notification.changed.repetition.previous', + '(previously: {{repetitionText}})', + { lng, repetitionText: oldRruleText }, + ), + ); + } + } - let notification = this.formatHeader( - i18next.t('meeting.room.notification.changed.headLine', 'CHANGES', { - lng, - }), - ); - if (meetingChanges.titleChanged) { - const newTitle = newMeeting.title; - const oldTitle = oldMeeting.title; + for (const change of meetingChanges.calendarChanges) { + if ( + change.changeType === 'addOverride' || + change.changeType === 'updateOverride' + ) { + const start: Date = parseICalDate(change.value.dtstart).toJSDate(); + const end: Date = parseICalDate(change.value.dtend).toJSDate(); - notification += this.createTitleLine(lng, newTitle); - notification += this.createTitleLine(lng, oldTitle, true); - } + notification += formatCurrent( + i18next.t( + 'meeting.room.notification.changed.occurrence.current', + 'A single meeting from a meeting series is moved to {{range, daterange}}', + { + lng, + range: [start, end], + formatParams: { + range: { + timeZone, + ...fullLongDateFormat, + }, + }, + }, + ), + ); - if (meetingChanges.timeChanged) { - const newStart = RoomMessageService.formatDateTime( - timeZone, - lng, - newMeeting.startTime, - ); - const newEnd = RoomMessageService.formatDateTime( - timeZone, - lng, - newMeeting.endTime, - ); - const oldStart = RoomMessageService.formatDateTime( - timeZone, - lng, - oldMeeting.startTime, - ); - const oldEnd = RoomMessageService.formatDateTime( - timeZone, - lng, - oldMeeting.endTime, - ); - notification += this.createStartEndTimesLine(lng, newStart, newEnd); - notification += this.createStartEndTimesLine(lng, oldStart, oldEnd, true); - } + const [prevStart, prevEnd] = + change.changeType === 'addOverride' + ? [ + parseICalDate(change.oldDtstart).toJSDate(), + parseICalDate(change.oldDtend).toJSDate(), + ] + : [ + parseICalDate(change.oldValue.dtstart).toJSDate(), + parseICalDate(change.oldValue.dtend).toJSDate(), + ]; - if (meetingChanges.descriptionChanged) { - const newDescription = newMeeting.description; - const oldDescription = oldMeeting.description; - notification += this.createDescriptionLine(lng, newDescription); - notification += this.createDescriptionLine(lng, oldDescription, true); - } + notification += formatPrevious( + i18next.t( + 'meeting.room.notification.changed.occurrence.previous', + '(previously: {{value, daterange}})', + { + lng, + value: [prevStart, prevEnd], + formatParams: { + value: { + timeZone, + ...fullLongDateFormat, + }, + }, + }, + ), + ); + } else if ( + change.changeType === 'deleteOverride' || + change.changeType === 'addExdate' + ) { + const [start, end] = + change.changeType === 'deleteOverride' + ? [ + parseICalDate(change.value.dtstart).toJSDate(), + parseICalDate(change.value.dtend).toJSDate(), + ] + : [ + parseICalDate(change.dtstart).toJSDate(), + parseICalDate(change.dtend).toJSDate(), + ]; - if (meetingChanges.calendarChanged) { - const newRruleText = isRecurringCalendarSourceEntry(newMeeting.calendar) - ? formatRRuleText(newMeeting.calendar[0].rrule, i18next.t, lng) - : i18next.t('meeting.room.notification.noRepetition', 'No repetition', { - lng, - }); - notification += this.createRepetitionLine(lng, newRruleText); - - const oldRruleText = isRecurringCalendarSourceEntry(oldMeeting.calendar) - ? formatRRuleText(oldMeeting.calendar[0].rrule, i18next.t, lng) - : i18next.t('meeting.room.notification.noRepetition', 'No repetition', { - lng, - }); - notification += this.createRepetitionLine(lng, oldRruleText, true); + notification += formatCurrent( + i18next.t( + 'meeting.room.notification.changed.occurrence.deleted', + 'A single meeting from a meeting series on {{range, daterange}} is deleted', + { + lng, + range: [start, end], + formatParams: { + range: { + timeZone, + ...fullLongDateFormat, + }, + }, + }, + ), + ); + } } await this.client.sendHtmlText(toRoomId, notification); } } + +function formatCurrent(text: string): string { + return `${text}
`; +} +function formatPrevious(text: string): string { + return `${text}
`; +} diff --git a/matrix-meetings-bot/src/shared/calendarUtils.test.ts b/matrix-meetings-bot/src/shared/calendarUtils.test.ts index 186f05a1..d3f62499 100644 --- a/matrix-meetings-bot/src/shared/calendarUtils.test.ts +++ b/matrix-meetings-bot/src/shared/calendarUtils.test.ts @@ -15,20 +15,16 @@ */ import { CalendarEntryDto, DateTimeEntryDto } from '../dto/CalendarEntryDto'; +import { mockCalendarEntry } from '../testUtils'; import { getForceDeletionTime, - getMeetingEndTime, - getMeetingStartTime, + getSingleOrRecurringEntry, } from './calendarUtils'; -let startTime: string; -let endTime: string; let calendar: CalendarEntryDto[]; let calendarWithRRule: CalendarEntryDto[]; beforeEach(() => { - startTime = '2020-01-01T00:00:00Z'; - endTime = '2020-01-01T01:00:00Z'; calendar = [ new CalendarEntryDto( 'uuid', @@ -46,52 +42,94 @@ beforeEach(() => { ]; }); -describe('getMeetingStartTime', () => { - it('should use start_time', () => { - expect(getMeetingStartTime(startTime, undefined)).toBe( - '2020-01-01T00:00:00Z', +describe('getSingleOrRecurringEntry', () => { + it('should get from single entry', () => { + expect( + getSingleOrRecurringEntry([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + ]), + ).toEqual( + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), ); }); - it('should use calendar', () => { - expect(getMeetingStartTime(undefined, calendar)).toBe( - '2020-01-02T00:00:00+01:00', + it('should get from single recurring entry', () => { + expect( + getSingleOrRecurringEntry([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]), + ).toEqual( + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), ); }); - it('should prefer calendar if both values are defined', () => { - expect(getMeetingStartTime(startTime, calendar)).toBe( - '2020-01-02T00:00:00+01:00', + it('should get from single recurring entry with existing overrides', () => { + expect( + getSingleOrRecurringEntry([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]), + ).toEqual( + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), ); }); - it('should throw if both values are undefined', () => { - expect(() => getMeetingStartTime(undefined, undefined)).toThrowError( - 'Unexpected input: Both start_time and calendar are undefined', - ); - }); -}); - -describe('getMeetingEndTime', () => { - it('should use end_time', () => { - expect(getMeetingEndTime(endTime, undefined)).toBe('2020-01-01T01:00:00Z'); - }); - - it('should use calendar', () => { - expect(getMeetingEndTime(undefined, calendar)).toBe( - '2020-01-02T01:00:00+01:00', - ); - }); - - it('should prefer calendar if both values are defined', () => { - expect(getMeetingEndTime(endTime, calendar)).toBe( - '2020-01-02T01:00:00+01:00', + it('should get from single recurring entry with existing overrides reordered', () => { + expect( + getSingleOrRecurringEntry([ + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + ]), + ).toEqual( + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), ); }); - it('should throw if both values are undefined', () => { - expect(() => getMeetingEndTime(undefined, undefined)).toThrowError( - 'Unexpected input: Both end_time and calendar are undefined', + it('should throw if calendar is empty', () => { + expect(() => getSingleOrRecurringEntry([])).toThrowError( + 'calendar must have single or recurring entry', ); }); }); diff --git a/matrix-meetings-bot/src/shared/calendarUtils.ts b/matrix-meetings-bot/src/shared/calendarUtils.ts index bc92571b..c4974f0c 100644 --- a/matrix-meetings-bot/src/shared/calendarUtils.ts +++ b/matrix-meetings-bot/src/shared/calendarUtils.ts @@ -16,62 +16,24 @@ import { CalendarEntryDto } from '../dto/CalendarEntryDto'; import { getCalendarEnd } from './calendarUtils/getCalendarEnd'; -import { parseICalDate, toISOString } from './dateTimeUtils'; +import { isRRuleOverrideEntry } from './calendarUtils/helpers'; /** - * Extract the start time of the first entry of the {@code calendar} param if present. - * - * @remarks This is only a temporary solution until we properly - * support recurrence rules. - * - * @param start_time - the start_time field of the meeting metadata - * @param calendar - the calendar field of the meeting metadata - * @returns the start time - * @throws if both start_time and calendar are undefined + * Extracts first single or recurring calendar entry. + * @param calendar meeting calendar */ -export function getMeetingStartTime( - start_time: string | undefined, - calendar: CalendarEntryDto[] | undefined, -): string { - if (calendar && calendar.length > 0) { - return toISOString(parseICalDate(calendar[0].dtstart)); - } - - if (start_time === undefined) { - throw new Error( - 'Unexpected input: Both start_time and calendar are undefined', - ); - } - - return start_time; -} - -/** - * Extract the end time of the first entry of the {@code calendar} param if present. - * - * @remarks This is only a temporary solution until we properly - * support recurrence rules. - * - * @param end_time - the end_time field of the meeting metadata - * @param calendar - the calendar field of the meeting metadata - * @returns the end time - * @throws if both end_time and calendar are undefined - */ -export function getMeetingEndTime( - end_time: string | undefined, - calendar: CalendarEntryDto[] | undefined, -): string { - if (calendar && calendar.length > 0) { - return toISOString(parseICalDate(calendar[0].dtend)); - } +export function getSingleOrRecurringEntry( + calendar: CalendarEntryDto[], +): CalendarEntryDto { + const entry = calendar.find((e) => !isRRuleOverrideEntry(e)); - if (end_time === undefined) { + if (entry === undefined) { throw new Error( - 'Unexpected input: Both end_time and calendar are undefined', + 'Unexpected input: calendar must have single or recurring entry', ); } - return end_time; + return entry; } /** diff --git a/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.test.ts b/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.test.ts new file mode 100644 index 00000000..825c9132 --- /dev/null +++ b/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.test.ts @@ -0,0 +1,541 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockCalendarEntry } from '../../testUtils'; +import { extractCalendarChange } from './extractCalendarChange'; + +describe('extractCalendarChange', () => { + it.each([ + { + name: 'handle empty array', + calendar: [], + newCalendar: [], + }, + { + name: 'nothing has changed', + calendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ], + newCalendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ], + }, + ])( + 'should not extract any single meeting changes for: $name', + ({ calendar, newCalendar }) => { + expect(extractCalendarChange(calendar, newCalendar)).toEqual([]); + }, + ); + + it('should extract updateSingleOrRecurringTime for: update existing single entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateSingleOrRecurringTime', + uid: 'entry-0', + oldValue: { + dtstart: { tzid: 'UTC', value: '20200109T100000' }, + dtend: { tzid: 'UTC', value: '20200109T110000' }, + }, + newValue: { + dtstart: { tzid: 'UTC', value: '20200111T100000' }, + dtend: { tzid: 'UTC', value: '20200111T110000' }, + }, + }, + ]); + }); + + it('should extract updateSingleOrRecurringTime for: update existing single recurring entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateSingleOrRecurringTime', + uid: 'entry-0', + oldValue: { + dtstart: { tzid: 'UTC', value: '20200109T100000' }, + dtend: { tzid: 'UTC', value: '20200109T110000' }, + }, + newValue: { + dtstart: { tzid: 'UTC', value: '20200111T100000' }, + dtend: { tzid: 'UTC', value: '20200111T110000' }, + }, + }, + ]); + }); + + it('should extract updateSingleOrRecurring for: update single recurring entry with existing overrides', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T100000', + dtend: '20200111T110000', + rrule: 'FREQ=DAILY', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateSingleOrRecurringTime', + uid: 'entry-0', + oldValue: { + dtstart: { tzid: 'UTC', value: '20200109T100000' }, + dtend: { tzid: 'UTC', value: '20200109T110000' }, + }, + newValue: { + dtstart: { tzid: 'UTC', value: '20200111T100000' }, + dtend: { tzid: 'UTC', value: '20200111T110000' }, + }, + }, + ]); + }); + + it('should extract updateSingleOrRecurringRrule for: update existing single entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateSingleOrRecurringRrule', + uid: 'entry-0', + oldValue: undefined, + newValue: 'FREQ=DAILY', + }, + ]); + }); + + it('should extract updateSingleOrRecurringRrule for: update existing single recurring entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY;COUNT=2', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateSingleOrRecurringRrule', + uid: 'entry-0', + oldValue: 'FREQ=DAILY', + newValue: 'FREQ=DAILY;COUNT=2', + }, + ]); + }); + + it('should extract added occurrence for: add an updated occurrence to a single recurring meeting', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'addOverride', + value: mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + oldDtstart: { tzid: 'UTC', value: '20200111T100000' }, + oldDtend: { tzid: 'UTC', value: '20200111T110000' }, + }, + ]); + }); + + it('should extract updated occurrence for: update a single occurrence of a recurring meeting', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200111T103000', + dtend: '20200111T113000', + recurrenceId: '20200111T100000', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + ]; + const newCalendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]; + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'updateOverride', + oldValue: mockCalendarEntry({ + dtstart: '20200111T103000', + dtend: '20200111T113000', + recurrenceId: '20200111T100000', + }), + value: mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + }, + ]); + }); + + it.each([ + { + name: 'delete an occurrence of a meeting', + calendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ], + newCalendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200215T100000'], + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ], + }, + { + name: 'delete an occurrence of a meeting with an existing exdate', + calendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + + // another entry that should stay + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ], + newCalendar: [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000', '20200215T100000'], + }), + mockCalendarEntry({ + uid: 'entry-1', + dtstart: '20200109T150000', + dtend: '20200109T160000', + rrule: 'FREQ=DAILY', + }), + ], + }, + ])( + 'should extract deleted occurrence for: $name', + ({ calendar, newCalendar }) => { + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'addExdate', + dtstart: { tzid: 'UTC', value: '20200215T100000' }, + dtend: { tzid: 'UTC', value: '20200215T110000' }, + }, + ]); + }, + ); + + it('should extract deleted occurrence for: delete existing overrides', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]; + + const newCalendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]; + + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'deleteOverride', + value: mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + }, + ]); + }); + + it('should extract deleted occurrence for: delete existing overrides when override before rrule entry', () => { + const calendar = [ + mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]; + + const newCalendar = [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]; + + expect(extractCalendarChange(calendar, newCalendar)).toEqual([ + { + changeType: 'deleteOverride', + value: mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + }, + ]); + }); +}); diff --git a/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.ts b/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.ts new file mode 100644 index 00000000..4eeba79f --- /dev/null +++ b/matrix-meetings-bot/src/shared/calendarUtils/extractCalendarChange.ts @@ -0,0 +1,267 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { differenceWith, isEqual } from 'lodash'; +import { CalendarEntryDto, DateTimeEntryDto } from '../../dto/CalendarEntryDto'; +import { formatICalDate, parseICalDate } from '../dateTimeUtils'; +import { isRRuleEntry, isRRuleOverrideEntry, isSingleEntry } from './helpers'; + +export type CalendarChange = + | UpdateSingleOrRecurringTimeChange + | UpdateSingleOrRecurringRruleChange + | AddOverrideChange + | UpdateOverrideChange + | DeleteOverrideChange + | AddExdateChange; + +type ChangeBase = { + changeType: string; +}; + +type SingleOrRecurringChangeBase = ChangeBase & { + uid: string; + oldValue: T; + newValue: T; +}; + +type UpdateSingleOrRecurringTimeChange = SingleOrRecurringChangeBase<{ + dtstart: DateTimeEntryDto; + dtend: DateTimeEntryDto; +}> & { + changeType: 'updateSingleOrRecurringTime'; +}; + +type UpdateSingleOrRecurringRruleChange = SingleOrRecurringChangeBase< + string | undefined +> & { + changeType: 'updateSingleOrRecurringRrule'; +}; + +type OverrideChangeBase = ChangeBase & { + /** + * Override entry. + */ + value: CalendarEntryDto & { + recurrenceId: DateTimeEntryDto; + }; +}; + +type AddOverrideChange = OverrideChangeBase & { + changeType: 'addOverride'; + + /** + * Old occurrence start date and time. + */ + oldDtstart: DateTimeEntryDto; + + /** + * Old occurrence end date and time. + */ + oldDtend: DateTimeEntryDto; +}; + +type UpdateOverrideChange = OverrideChangeBase & { + changeType: 'updateOverride'; + + /** + * Previous override entry. + */ + oldValue: CalendarEntryDto & { + recurrenceId: DateTimeEntryDto; + }; +}; + +type DeleteOverrideChange = OverrideChangeBase & { + changeType: 'deleteOverride'; +}; + +type AddExdateChange = { + changeType: 'addExdate'; + + /** + * Excluded occurrence start date and time. + */ + dtstart: DateTimeEntryDto; + + /** + * Excluded occurrence end date and time. + */ + dtend: DateTimeEntryDto; +}; + +interface MapEntry { + singleOrRecurringEntry?: CalendarEntryDto; + overrideMap?: Map< + number, + CalendarEntryDto & { + recurrenceId: DateTimeEntryDto; + } + >; +} + +/** + * Compares calendars of a recurring meeting modified by meetings widget and extracts calendar changes: + * - 'updateSingleOrRecurringTime' single or recurring entry time change + * - 'updateSingleOrRecurringRrule' single or recurring entry rrule change + * - 'addOverride', 'updateOverride', 'deleteOverride' override entries changes + * - 'addExdate' exdate added to rrule entry change + */ +export function extractCalendarChange( + calendarEntries: CalendarEntryDto[], + newCalendarEntries: CalendarEntryDto[], +): CalendarChange[] { + const changes: CalendarChange[] = []; + + const map = new Map(); + + for (const calendarEntry of calendarEntries) { + let mapEntry = map.get(calendarEntry.uid); + if (!mapEntry) { + mapEntry = { + singleOrRecurringEntry: + isSingleEntry(calendarEntry) || isRRuleEntry(calendarEntry) + ? calendarEntry + : undefined, + overrideMap: undefined, + }; + map.set(calendarEntry.uid, mapEntry); + } else if (isSingleEntry(calendarEntry) || isRRuleEntry(calendarEntry)) { + mapEntry.singleOrRecurringEntry = calendarEntry; // assign calendar entry if map entry was created by override + } + if (isRRuleOverrideEntry(calendarEntry)) { + let overrideMap = mapEntry.overrideMap; + if (!overrideMap) { + overrideMap = new Map(); + mapEntry.overrideMap = overrideMap; + } + overrideMap.set( + +parseICalDate(calendarEntry.recurrenceId), + calendarEntry, + ); + } + } + + for (const newCalendarEntry of newCalendarEntries) { + if (isRRuleOverrideEntry(newCalendarEntry)) { + // handle override + const calendarEntryOverride = map + .get(newCalendarEntry.uid) + ?.overrideMap?.get(+parseICalDate(newCalendarEntry.recurrenceId)); + if (calendarEntryOverride) { + if (!isEqual(newCalendarEntry, calendarEntryOverride)) { + changes.push({ + changeType: 'updateOverride', + oldValue: calendarEntryOverride, + value: newCalendarEntry, + }); + } + } else { + const calendarEntry = map.get(newCalendarEntry.uid) + ?.singleOrRecurringEntry; + if (calendarEntry && isRRuleEntry(calendarEntry)) { + const duration = parseICalDate(calendarEntry.dtend).diff( + parseICalDate(calendarEntry.dtstart), + ); + changes.push({ + changeType: 'addOverride', + value: newCalendarEntry, + oldDtstart: newCalendarEntry.recurrenceId, + oldDtend: formatICalDate( + parseICalDate(newCalendarEntry.recurrenceId).plus(duration), + newCalendarEntry.recurrenceId.tzid, + ), + }); + } + } + } else { + // handle series + const calendarEntry = map.get(newCalendarEntry.uid) + ?.singleOrRecurringEntry; + if ( + isRRuleEntry(newCalendarEntry) && + calendarEntry && + isRRuleEntry(calendarEntry) + ) { + const newExdates = differenceWith( + newCalendarEntry.exdate, + calendarEntry.exdate ?? [], + isEqual, + ); + for (const exdate of newExdates) { + const override = map + .get(newCalendarEntry.uid) + ?.overrideMap?.get(+parseICalDate(exdate)); + if (override) { + changes.push({ + changeType: 'deleteOverride', + value: override, + }); + } else { + const duration = parseICalDate(calendarEntry.dtend).diff( + parseICalDate(calendarEntry.dtstart), + ); + changes.push({ + changeType: 'addExdate', + dtstart: exdate, + dtend: formatICalDate( + parseICalDate(exdate).plus(duration), + exdate.tzid, + ), + }); + } + } + } + if (calendarEntry) { + if ( + !isEqual( + { + dtstart: newCalendarEntry.dtstart, + dtend: newCalendarEntry.dtend, + }, + { + dtstart: calendarEntry.dtstart, + dtend: calendarEntry.dtend, + }, + ) + ) { + changes.push({ + uid: newCalendarEntry.uid, + changeType: 'updateSingleOrRecurringTime', + oldValue: { + dtstart: calendarEntry.dtstart, + dtend: calendarEntry.dtend, + }, + newValue: { + dtstart: newCalendarEntry.dtstart, + dtend: newCalendarEntry.dtend, + }, + }); + } + + if (newCalendarEntry.rrule !== calendarEntry.rrule) { + changes.push({ + changeType: 'updateSingleOrRecurringRrule', + uid: newCalendarEntry.uid, + oldValue: calendarEntry.rrule, + newValue: newCalendarEntry.rrule, + }); + } + } + } + } + + return changes; +} diff --git a/matrix-meetings-bot/src/shared/calendarUtils/helpers.ts b/matrix-meetings-bot/src/shared/calendarUtils/helpers.ts index 1efeff18..959b91af 100644 --- a/matrix-meetings-bot/src/shared/calendarUtils/helpers.ts +++ b/matrix-meetings-bot/src/shared/calendarUtils/helpers.ts @@ -40,17 +40,3 @@ export function isRRuleOverrideEntry( export function isFiniteSeries(rruleOptions: Partial): boolean { return rruleOptions.count !== undefined || rruleOptions.until !== undefined; } - -export function isRecurringCalendarSourceEntry( - entries?: CalendarEntryDto[], -): entries is - | [CalendarEntryDto & { rrule: string }] - | [ - CalendarEntryDto & { rrule: string }, - Required>, - ] { - return ( - typeof entries?.[0].rrule === 'string' && - (entries[1] === undefined || entries[1].recurrenceId !== undefined) - ); -} diff --git a/matrix-meetings-bot/src/shared/index.ts b/matrix-meetings-bot/src/shared/index.ts index fb65a27b..1e3001f6 100644 --- a/matrix-meetings-bot/src/shared/index.ts +++ b/matrix-meetings-bot/src/shared/index.ts @@ -14,9 +14,5 @@ * limitations under the License. */ -export { - getForceDeletionTime, - getMeetingEndTime, - getMeetingStartTime, -} from './calendarUtils'; +export { getForceDeletionTime } from './calendarUtils'; export { formatICalDate, parseICalDate, toISOString } from './dateTimeUtils'; diff --git a/matrix-meetings-bot/src/static/locales/de/translation.json b/matrix-meetings-bot/src/static/locales/de/translation.json index 2d08cdbd..bd101ae3 100644 --- a/matrix-meetings-bot/src/static/locales/de/translation.json +++ b/matrix-meetings-bot/src/static/locales/de/translation.json @@ -31,7 +31,7 @@ ], "meeting": { "invite": { - "message": "📅 {{range}}
$t(meeting.invite.messageRecurrence, {\"context\": \"{{recurrenceContext}}\" })
$t(meeting.invite.messageByOrganizer, {\"context\": \"{{organizerContext}}\" })$t(meeting.invite.messageDescription, {\"context\": \"{{descriptionContext}}\" })", + "message": "📅 {{range, daterange}}
$t(meeting.invite.messageRecurrence, {\"context\": \"{{recurrenceContext}}\" })
$t(meeting.invite.messageByOrganizer, {\"context\": \"{{organizerContext}}\" })$t(meeting.invite.messageDescription, {\"context\": \"{{descriptionContext}}\" })", "messageByOrganizer_none": "", "messageByOrganizer_present": "\n{{organizerDisplayName}} hat dich zu dieser Besprechung eingeladen", "messageDescription_none": "", @@ -43,14 +43,19 @@ "notification": { "changed": { "date": { - "current": "Zeitraum: Von {{start}} bis {{end}}", - "previous": "(bislang: Von {{start}} bis {{end}})" + "current": "Zeitraum: {{range, daterange}}", + "previous": "(bislang: {{range, daterange}})" }, "description": { "current": "Beschreibung: {{description}}", "previous": "(bislang: {{description}})" }, "headLine": "Änderungen", + "occurrence": { + "current": "Eine einzelne Besprechung aus einer Besprechungsserie wurde verschoben auf {{range, daterange}}", + "deleted": "Eine einzelne Besprechung aus einer Besprechungsserie wurde gelöscht: {{range, daterange}}", + "previous": "(bislang: {{value, daterange}})" + }, "repetition": { "current": "Die Besprechung wiederholen: {{repetitionText}}", "previous": "(bislang: {{repetitionText}})" diff --git a/matrix-meetings-bot/src/static/locales/en/translation.json b/matrix-meetings-bot/src/static/locales/en/translation.json index 8de4097f..3fd67128 100644 --- a/matrix-meetings-bot/src/static/locales/en/translation.json +++ b/matrix-meetings-bot/src/static/locales/en/translation.json @@ -31,7 +31,7 @@ ], "meeting": { "invite": { - "message": "📅 {{range}}
$t(meeting.invite.messageRecurrence, {\"context\": \"{{recurrenceContext}}\" })
$t(meeting.invite.messageByOrganizer, {\"context\": \"{{organizerContext}}\" })$t(meeting.invite.messageDescription, {\"context\": \"{{descriptionContext}}\" })", + "message": "📅 {{range, daterange}}
$t(meeting.invite.messageRecurrence, {\"context\": \"{{recurrenceContext}}\" })
$t(meeting.invite.messageByOrganizer, {\"context\": \"{{organizerContext}}\" })$t(meeting.invite.messageDescription, {\"context\": \"{{descriptionContext}}\" })", "messageByOrganizer_none": "", "messageByOrganizer_present": "\nyou've been invited to a meeting by {{organizerDisplayName}}", "messageDescription_none": "", @@ -43,14 +43,19 @@ "notification": { "changed": { "date": { - "current": "Date: {{start}} to {{end}}", - "previous": "(previously: {{start}} to {{end}})" + "current": "Date: {{range, daterange}}", + "previous": "(previously: {{range, daterange}})" }, "description": { "current": "Description: {{description}}", "previous": "(previously: {{description}})" }, "headLine": "CHANGES", + "occurrence": { + "current": "A single meeting from a meeting series is moved to {{range, daterange}}", + "deleted": "A single meeting from a meeting series on {{range, daterange}} is deleted", + "previous": "(previously: {{value, daterange}})" + }, "repetition": { "current": "Repeat meeting: {{repetitionText}}", "previous": "(previously: {{repetitionText}})" diff --git a/matrix-meetings-bot/src/testUtils.ts b/matrix-meetings-bot/src/testUtils.ts new file mode 100644 index 00000000..d32a7952 --- /dev/null +++ b/matrix-meetings-bot/src/testUtils.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2023 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CalendarEntryDto } from './dto/CalendarEntryDto'; + +/** + * Create a calendar entry that starts at the given times. + * + * @param dtstart - the start time in the iCalendar format in UTC + * @param dtend - the start time in the iCalendar format in UTC + * + * @remarks Only use for tests + */ +export function mockCalendarEntry({ + uid = 'entry-0', + dtstart, + dtend, + rrule, + exdate, + recurrenceId, +}: { + uid?: string; + dtstart: string; + dtend: string; + rrule?: string; + exdate?: string[]; + recurrenceId?: string; +}): CalendarEntryDto { + return { + uid, + dtstart: { tzid: 'UTC', value: dtstart }, + dtend: { tzid: 'UTC', value: dtend }, + rrule, + exdate: exdate?.map((value) => ({ tzid: 'UTC', value })), + recurrenceId: + recurrenceId !== undefined + ? { tzid: 'UTC', value: recurrenceId } + : undefined, + }; +} diff --git a/matrix-meetings-bot/src/util/IMeetingChanges.ts b/matrix-meetings-bot/src/util/IMeetingChanges.ts index d98f290b..7c0614b1 100644 --- a/matrix-meetings-bot/src/util/IMeetingChanges.ts +++ b/matrix-meetings-bot/src/util/IMeetingChanges.ts @@ -15,16 +15,15 @@ */ import { IMeeting } from '../model/IMeeting'; -import { isRecurringCalendarSourceEntry } from '../shared/calendarUtils/helpers'; +import { + CalendarChange, + extractCalendarChange, +} from '../shared/calendarUtils/extractCalendarChange'; export interface IMeetingChanges { titleChanged: boolean; descriptionChanged: boolean; - startTimeChanged: boolean; - endTimeChanged: boolean; - calendarChanged: boolean; - - timeChanged: boolean; + calendarChanges: CalendarChange[]; anythingChanged: boolean; } @@ -36,26 +35,18 @@ class MeetingChangesHelper { const titleChanged = newMeeting.title !== oldMeeting.title; const descriptionChanged = newMeeting.description !== oldMeeting.description; - const startTimeChanged = newMeeting.startTime !== oldMeeting.startTime; - const endTimeChanged = newMeeting.endTime !== oldMeeting.endTime; - const timeChanged = startTimeChanged || endTimeChanged; - const newRrule = isRecurringCalendarSourceEntry(newMeeting.calendar) - ? newMeeting.calendar[0].rrule - : undefined; - const oldRrule = isRecurringCalendarSourceEntry(oldMeeting.calendar) - ? oldMeeting.calendar[0].rrule - : undefined; - const calendarChanged = newRrule !== oldRrule; + + const calendarChanges = extractCalendarChange( + oldMeeting.calendar, + newMeeting.calendar, + ); const anythingChanged = - titleChanged || descriptionChanged || timeChanged || calendarChanged; + titleChanged || descriptionChanged || calendarChanges.length > 0; return { titleChanged, descriptionChanged, - startTimeChanged, - endTimeChanged, - calendarChanged, - timeChanged, + calendarChanges, anythingChanged, }; } diff --git a/matrix-meetings-bot/src/util/TemplateHelper.ts b/matrix-meetings-bot/src/util/TemplateHelper.ts index 72bcf8cf..469b5afb 100644 --- a/matrix-meetings-bot/src/util/TemplateHelper.ts +++ b/matrix-meetings-bot/src/util/TemplateHelper.ts @@ -15,9 +15,11 @@ */ import i18next from 'i18next'; +import { fullNumericDateFormat } from '../dateFormat'; import { CalendarEntryDto } from '../dto/CalendarEntryDto'; import { IUserContext } from '../model/IUserContext'; -import { isRecurringCalendarSourceEntry } from '../shared/calendarUtils/helpers'; +import { parseICalDate } from '../shared'; +import { getSingleOrRecurringEntry } from '../shared/calendarUtils'; import { formatRRuleText } from '../shared/format'; export class TemplateHelper { @@ -29,9 +31,9 @@ export class TemplateHelper { const lng = userContext.locale ?? 'en'; const timeZone = userContext.timezone ?? 'UTC'; - const recurrence = isRecurringCalendarSourceEntry(meeting.calendar) - ? formatRRuleText(meeting.calendar[0].rrule, i18next.t, lng) - : ''; + const entry = getSingleOrRecurringEntry(meeting.calendar); + const rrule = entry.rrule; + const recurrence = rrule ? formatRRuleText(rrule, i18next.t, lng) : ''; /* IMPORTANT: This comments define the nested keys used below and are used to extract them via i18next-parser @@ -48,21 +50,25 @@ export class TemplateHelper { const message = i18next.t( 'meeting.invite.message', - '📅 {{range}}
$t(meeting.invite.messageRecurrence, {"context": "{{recurrenceContext}}" })
$t(meeting.invite.messageByOrganizer, {"context": "{{organizerContext}}" })$t(meeting.invite.messageDescription, {"context": "{{descriptionContext}}" })', + '📅 {{range, daterange}}
$t(meeting.invite.messageRecurrence, {"context": "{{recurrenceContext}}" })
$t(meeting.invite.messageByOrganizer, {"context": "{{organizerContext}}" })$t(meeting.invite.messageDescription, {"context": "{{descriptionContext}}" })', { lng, - range: dateRangeFormat( - new Date(meeting.startTime), - new Date(meeting.endTime), - lng, - { ...fullNumericDateFormat, timeZone }, - ), + range: [ + parseICalDate(entry.dtstart).toJSDate(), + parseICalDate(entry.dtend).toJSDate(), + ], recurrenceContext: recurrence ? 'present' : 'none', recurrence, organizerContext: organizerDisplayName ? 'present' : 'none', organizerDisplayName, descriptionContext: meeting.description.length > 0 ? 'present' : 'none', description: meeting.description, + formatParams: { + range: { + timeZone, + ...fullNumericDateFormat, + }, + }, }, ); @@ -75,29 +81,7 @@ export class TemplateHelper { export const templateHelper = new TemplateHelper(); -const fullNumericDateFormat = { - hour: 'numeric', - minute: 'numeric', - month: 'numeric', - year: 'numeric', - day: 'numeric', - timeZoneName: 'short', -}; - -function dateRangeFormat( - start: Date, - end: Date, - lng: string | undefined, - options: any, -): string { - const formatter = new Intl.DateTimeFormat(lng, options); - // @ts-ignore: DateTimeFormat#formatRange will be available in TypeScript >4.7.2 - return formatter.formatRange(start, end); -} - export interface InviteParams { description: string; - startTime: string; - endTime: string; - calendar?: CalendarEntryDto[]; + calendar: CalendarEntryDto[]; } diff --git a/matrix-meetings-bot/src/util/migrateMeetingTime.test.ts b/matrix-meetings-bot/src/util/migrateMeetingTime.test.ts index 30c1369d..2d889955 100644 --- a/matrix-meetings-bot/src/util/migrateMeetingTime.test.ts +++ b/matrix-meetings-bot/src/util/migrateMeetingTime.test.ts @@ -14,62 +14,259 @@ * limitations under the License. */ -import { CalendarEntryDto } from '../dto/CalendarEntryDto'; -import { IMeetingTime, migrateMeetingTime } from './migrateMeetingTime'; +import { mockCalendarEntry } from '../testUtils'; +import { migrateMeetingTime } from './migrateMeetingTime'; describe('migrateMeetingTime', () => { - it('should change to calendar model', () => { + it('should migrate', () => { + expect( + migrateMeetingTime({ + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }), + ).toEqual([ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20200101T080000' }, + dtend: { tzid: 'UTC', value: '20200101T100000' }, + }, + ]); + }); + + it('should migrate when single entry calendar', () => { + expect( + migrateMeetingTime( + { + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }, + undefined, + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + }), + ]); + }); + + it('should migrate single recurring entry calendar', () => { + expect( + migrateMeetingTime( + { + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }, + undefined, + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + }), + ]); + }); + + it('should migrate when single recurring entry with existing overrides', () => { expect( migrateMeetingTime( { - start_time: '2022-01-01T00:00:00.000Z', - end_time: '2022-01-03T00:00:00.000Z', + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }, + undefined, + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + }), + ]); + }); + + it('should migrate when rrule', () => { + expect( + migrateMeetingTime( + { + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', calendar: undefined, }, 'FREQ=DAILY;COUNT=1', + undefined, ), - ).toEqual({ - start_time: undefined, - end_time: undefined, - calendar: [ + ).toEqual([ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20200101T080000' }, + dtend: { tzid: 'UTC', value: '20200101T100000' }, + rrule: 'FREQ=DAILY;COUNT=1', + }, + ]); + }); + + it('should migrate when rrule and single entry calendar', () => { + expect( + migrateMeetingTime( + { + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }, + 'FREQ=DAILY;COUNT=1', + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + rrule: 'FREQ=DAILY;COUNT=1', + }), + ]); + }); + + it('should migrate when rrule and single recurring entry calendar', () => { + expect( + migrateMeetingTime( { - uid: expect.any(String), - dtstart: { tzid: 'UTC', value: '20220101T000000' }, - dtend: { tzid: 'UTC', value: '20220103T000000' }, - rrule: 'FREQ=DAILY;COUNT=1', + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, }, - ], - } as IMeetingTime); + 'FREQ=DAILY;COUNT=1', + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + rrule: 'FREQ=DAILY;COUNT=1', + }), + ]); }); - it.each<{ - calendar: CalendarEntryDto[] | undefined; - externalRrule: string | undefined; - }>([ - { calendar: undefined, externalRrule: undefined }, - { calendar: [], externalRrule: undefined }, - { calendar: [], externalRrule: 'FREQ=DAILY;COUNT=1' }, - { - calendar: [ + it('should migrate when rrule and single recurring entry with existing overrides', () => { + expect( + migrateMeetingTime( { - uid: 'some-id', - dtstart: { tzid: 'UTC', value: '20220101T000000' }, - dtend: { tzid: 'UTC', value: '20220103T000000' }, - rrule: 'FREQ=DAILY;COUNT=1', + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, }, - ], - externalRrule: undefined, - }, - ])( - 'should not change meeting time with %s', - ({ calendar, externalRrule }) => { - const meetingTime = { - start_time: '2022-01-01T00:00:00.000Z', - end_time: '2022-01-03T00:00:00.000Z', - calendar, - }; + 'FREQ=DAILY;COUNT=1', + [ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + rrule: 'FREQ=DAILY;COUNT=1', + }), + ]); + }); - expect(meetingTime).toBe(migrateMeetingTime(meetingTime, externalRrule)); + it('should migrate when rrule and single recurring entry with existing overrides reordered', () => { + expect( + migrateMeetingTime( + { + start_time: '2020-01-01T08:00:00.000Z', + end_time: '2020-01-01T10:00:00.000Z', + calendar: undefined, + }, + 'FREQ=DAILY;COUNT=1', + [ + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + ], + ), + ).toEqual([ + mockCalendarEntry({ + dtstart: '20200101T080000', + dtend: '20200101T100000', + rrule: 'FREQ=DAILY;COUNT=1', + }), + ]); + }); + + it.each([ + { start_time: undefined, end_time: undefined, calendar: undefined }, + { + start_time: '2022-01-01T00:00:00.000Z', + end_time: undefined, + calendar: undefined, }, - ); + { + start_time: undefined, + end_time: '2022-01-03T00:00:00.000Z', + calendar: undefined, + }, + ])('should fail when %s', (meetingTime) => { + expect(() => + migrateMeetingTime(meetingTime, undefined, undefined), + ).toThrowError( + 'Unexpected input: either start_time with end_time or calendar should be provided', + ); + }); }); diff --git a/matrix-meetings-bot/src/util/migrateMeetingTime.ts b/matrix-meetings-bot/src/util/migrateMeetingTime.ts index c6ee2912..4c1ea038 100644 --- a/matrix-meetings-bot/src/util/migrateMeetingTime.ts +++ b/matrix-meetings-bot/src/util/migrateMeetingTime.ts @@ -26,31 +26,32 @@ export interface IMeetingTime { } /** - * Changes meeting time from start and end time model to calendar model if external rrule is provided. + * Changes meeting time from start and end time model to calendar model. * @param meetingTime start, end, calendar data * @param externalRrule external rrule if available + * @param existingCalendar meeting current calendar if exists, is used if migration is needed */ export function migrateMeetingTime( meetingTime: IMeetingTime, - externalRrule?: string, -): IMeetingTime { - if ( - meetingTime.start_time && - meetingTime.end_time && - !meetingTime.calendar && - externalRrule - ) { - return { - calendar: [ - { - uid: uuiv4(), - dtstart: formatICalDate(DateTime.fromISO(meetingTime.start_time)), - dtend: formatICalDate(DateTime.fromISO(meetingTime.end_time)), - rrule: externalRrule, - }, - ], - }; + externalRrule?: string | undefined, + existingCalendar?: CalendarEntryDto[], +): CalendarEntryDto[] { + if (meetingTime.start_time && meetingTime.end_time && !meetingTime.calendar) { + return [ + { + uid: existingCalendar?.[0]?.uid ?? uuiv4(), + dtstart: formatICalDate(DateTime.fromISO(meetingTime.start_time)), + dtend: formatICalDate(DateTime.fromISO(meetingTime.end_time)), + rrule: externalRrule, + }, + ]; } - return meetingTime; + if (meetingTime.calendar) { + return meetingTime.calendar; + } else { + throw new Error( + 'Unexpected input: either start_time with end_time or calendar should be provided', + ); + } } diff --git a/matrix-meetings-bot/test/MeetingService.test.ts b/matrix-meetings-bot/test/MeetingService.test.ts index 1229cd3a..70fb1c02 100644 --- a/matrix-meetings-bot/test/MeetingService.test.ts +++ b/matrix-meetings-bot/test/MeetingService.test.ts @@ -331,8 +331,14 @@ describe('test relevant functionality of MeetingService', () => { expect(map[StateEventName.M_ROOM_GUEST_ACCESS]).toBeDefined(); const nic_meeting = map[StateEventName.NIC_MEETINGS_METADATA_EVENT]; - expect(nic_meeting.content.start_time).toBe(START); - expect(nic_meeting.content.end_time).toBe(END); + expect(nic_meeting.content.start_time).toBeUndefined(); + expect(nic_meeting.content.end_time).toBeUndefined(); + expect(nic_meeting.content.calendar[0]?.dtstart.value).toBe( + '20201111T140721', + ); + expect(nic_meeting.content.calendar[0]?.dtend.value).toBe( + '20221111T140721', + ); expect(nic_meeting.content.locale).toBeUndefined(); expect(nic_meeting.content.timezone).toBeUndefined(); @@ -350,8 +356,7 @@ describe('test relevant functionality of MeetingService', () => { if (!appConfig.auto_deletion_offset) { expect(nic_meeting.content.auto_deletion_offset).toBeUndefined(); } else { - expect(nic_meeting.content.auto_deletion_offset).toBeDefined(); - expect(nic_meeting.content.auto_deletion_offset).toBe(5); + expect(nic_meeting.content.force_deletion_at).toBeDefined(); } expect(roomProps.power_level_content_override).toBeDefined(); @@ -1224,9 +1229,14 @@ describe('test relevant functionality of MeetingService', () => { type: StateEventName.NIC_MEETINGS_METADATA_EVENT as string, ...stateEventStub, content: { - start_time: startTime, - end_time: endTime, - auto_deletion_offset: autoDeletionOffset, + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'UTC', value: '20220101T000000' }, + dtend: { tzid: 'UTC', value: '20220103T000000' }, + }, + ], + force_deletion_at: new Date('2022-01-03T00:05:00Z').getTime(), creator: CURRENT_USER, external_data: externalData, }, @@ -1291,13 +1301,12 @@ describe('test relevant functionality of MeetingService', () => { }, ]; - const stateEvents: any[] = [ + when(clientMock.getRoomState(roomId)).thenResolve([ roomCreationEvent, nicMeetingMetadataEvent, powerLevelsEvent, ...memberEvents, - ]; - when(clientMock.getRoomState(roomId)).thenResolve(stateEvents); + ]); const startTime1 = '2022-02-01T00:00:00Z'; const endTime1 = '2022-02-03T00:00:00Z'; @@ -1321,23 +1330,27 @@ describe('test relevant functionality of MeetingService', () => { ); await meetingService.updateMeetingDetails(userContext, meetingDetails); - const meetingMetadataEventContentUpdated: IMeetingsMetadataEventContent = { - creator: CURRENT_USER, - start_time: startTime1, - end_time: endTime1, - calendar: undefined, - force_deletion_at: undefined, - auto_deletion_offset: autoDeletionOffset, - external_data: externalData1, - }; - verify( - clientMock.sendStateEvent( - roomId, - StateEventName.NIC_MEETINGS_METADATA_EVENT, - '', - deepEqual(meetingMetadataEventContentUpdated), - ), - ).once(); + expect( + getArgsFromCaptor(capture(clientMock.sendStateEvent)) + .filter( + ([, type]) => type === StateEventName.NIC_MEETINGS_METADATA_EVENT, + ) + .map(([, , , content]) => content), + ).toEqual([ + { + creator: CURRENT_USER, + calendar: [ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20220201T000000' }, + dtend: { tzid: 'UTC', value: '20220203T000000' }, + rrule: undefined, + }, + ], + force_deletion_at: new Date('2022-02-03T00:05:00Z').getTime(), + external_data: externalData1, + }, + ]); verify( clientMock.sendStateEvent( @@ -1406,7 +1419,7 @@ describe('test relevant functionality of MeetingService', () => { // check if the room can be upgraded to the new data model meetingDetails.calendar = [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, }, @@ -1415,13 +1428,21 @@ describe('test relevant functionality of MeetingService', () => { delete meetingDetails.end_time; await meetingService.updateMeetingDetails(userContext, meetingDetails); - const meetingMetadataEventContentUpdated1: IMeetingsMetadataEventContent = { + expect( + last( + getArgsFromCaptor(capture(clientMock.sendStateEvent)) + .filter( + ([, type]) => type === StateEventName.NIC_MEETINGS_METADATA_EVENT, + ) + .map(([, , , content]) => content), + ), + ).toEqual({ creator: CURRENT_USER, start_time: undefined, end_time: undefined, calendar: [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, }, @@ -1429,22 +1450,14 @@ describe('test relevant functionality of MeetingService', () => { auto_deletion_offset: undefined, force_deletion_at: new Date('2022-02-03T00:05:00Z').getTime(), external_data: externalData1, - }; - verify( - clientMock.sendStateEvent( - roomId, - StateEventName.NIC_MEETINGS_METADATA_EVENT, - '', - deepEqual(meetingMetadataEventContentUpdated1), - ), - ).once(); + }); let msg = capture(clientMock.sendHtmlText).last()[1]; expect(msg).not.toContain('Repeat meeting'); meetingDetails.calendar = [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, rrule: 'FREQ=DAILY;COUNT=3', @@ -1452,13 +1465,21 @@ describe('test relevant functionality of MeetingService', () => { ]; await meetingService.updateMeetingDetails(userContext, meetingDetails); - const meetingMetadataEventContentUpdated2: IMeetingsMetadataEventContent = { + expect( + last( + getArgsFromCaptor(capture(clientMock.sendStateEvent)) + .filter( + ([, type]) => type === StateEventName.NIC_MEETINGS_METADATA_EVENT, + ) + .map(([, , , content]) => content), + ), + ).toEqual({ creator: CURRENT_USER, start_time: undefined, end_time: undefined, calendar: [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, rrule: 'FREQ=DAILY;COUNT=3', @@ -1467,43 +1488,38 @@ describe('test relevant functionality of MeetingService', () => { auto_deletion_offset: undefined, force_deletion_at: new Date('2022-02-05T00:05:00Z').getTime(), external_data: externalData1, - }; - verify( - clientMock.sendStateEvent( - roomId, - StateEventName.NIC_MEETINGS_METADATA_EVENT, - '', - deepEqual(meetingMetadataEventContentUpdated2), - ), - ).once(); + }); msg = capture(clientMock.sendHtmlText).last()[1]; expect(msg).toContain('Repeat meeting: Every day for 3 times'); expect(msg).toContain('(previously: )'); - // check if the can be moved back to the old data model delete meetingDetails.calendar; meetingDetails.start_time = '2022-02-01T10:00:00Z'; meetingDetails.end_time = '2022-02-01T11:00:00Z'; await meetingService.updateMeetingDetails(userContext, meetingDetails); - const meetingMetadataEventContentUpdated3: IMeetingsMetadataEventContent = { + expect( + last( + getArgsFromCaptor(capture(clientMock.sendStateEvent)) + .filter( + ([, type]) => type === StateEventName.NIC_MEETINGS_METADATA_EVENT, + ) + .map(([, , , content]) => content), + ), + ).toEqual({ creator: CURRENT_USER, - start_time: '2022-02-01T10:00:00Z', - end_time: '2022-02-01T11:00:00Z', - calendar: undefined, - auto_deletion_offset: 0, - force_deletion_at: undefined, + calendar: [ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20220201T100000' }, + dtend: { tzid: 'UTC', value: '20220201T110000' }, + rrule: undefined, + }, + ], + force_deletion_at: new Date('2022-02-01T11:05:00Z').getTime(), external_data: externalData1, - }; - verify( - clientMock.sendStateEvent( - roomId, - StateEventName.NIC_MEETINGS_METADATA_EVENT, - '', - deepEqual(meetingMetadataEventContentUpdated3), - ), - ).once(); + }); msg = capture(clientMock.sendHtmlText).last()[1]; expect(msg).not.toContain('Repeat meeting: '); @@ -1517,6 +1533,23 @@ describe('test relevant functionality of MeetingService', () => { }, }; + when(clientMock.getRoomState(roomId)).thenResolve([ + roomCreationEvent, + { + type: StateEventName.NIC_MEETINGS_METADATA_EVENT as string, + ...stateEventStub, + content: { + start_time: startTime, + end_time: endTime, + auto_deletion_offset: autoDeletionOffset, + creator: CURRENT_USER, + external_data: externalData, + }, + }, + powerLevelsEvent, + ...memberEvents, + ]); + const meetingDetailsOx = new MeetingUpdateDetailsDto( roomId, startTime, @@ -1538,17 +1571,81 @@ describe('test relevant functionality of MeetingService', () => { ), ).toEqual({ creator: CURRENT_USER, - start_time: startTime, - end_time: endTime, - calendar: undefined, - force_deletion_at: undefined, - auto_deletion_offset: autoDeletionOffset, + calendar: [ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20220101T000000' }, + dtend: { tzid: 'UTC', value: '20220103T000000' }, + rrule: undefined, + }, + ], + force_deletion_at: new Date('2022-01-03T00:05:00Z').getTime(), external_data: externalDataOx, } as IMeetingsMetadataEventContent); msg = capture(clientMock.sendHtmlText).last()[1]; expect(msg).not.toContain('Repeat meeting: '); + when(clientMock.getRoomState(roomId)).thenResolve([ + roomCreationEvent, + { + type: StateEventName.NIC_MEETINGS_METADATA_EVENT as string, + ...stateEventStub, + content: { + start_time: startTime, + end_time: endTime, + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'UTC', value: '20220101T000000' }, + dtend: { tzid: 'UTC', value: '20220103T000000' }, + }, + ], + force_deletion_at: new Date('2022-01-03T00:05:00Z').getTime(), + creator: CURRENT_USER, + external_data: externalData, + }, + }, + powerLevelsEvent, + ...memberEvents, + ]); + + await meetingService.updateMeetingDetails( + userContext, + new MeetingUpdateDetailsDto( + roomId, + '2022-01-01T01:00:00.000Z', + '2022-01-03T01:00:00.000Z', + undefined, + title1, + description1, + externalDataOx, + ), + ); + + expect( + last( + getArgsFromCaptor(capture(clientMock.sendStateEvent)) + .filter( + ([, type]) => type === StateEventName.NIC_MEETINGS_METADATA_EVENT, + ) + .map(([, , , content]) => content), + ), + ).toEqual({ + creator: CURRENT_USER, + calendar: [ + { + uid: 'entry-0', // id must stay! + dtstart: { tzid: 'UTC', value: '20220101T010000' }, + dtend: { tzid: 'UTC', value: '20220103T010000' }, + rrule: undefined, + }, + ], + auto_deletion_offset: undefined, + force_deletion_at: new Date('2022-01-03T01:05:00Z').getTime(), + external_data: externalDataOx, + } as IMeetingsMetadataEventContent); + // OX meeting with non-empty rrules should send 'net.nordeck.meetings.metadata' event with calendar const externalDataOx1: ExternalData = { 'io.ox': { @@ -1571,11 +1668,9 @@ describe('test relevant functionality of MeetingService', () => { ), ).toEqual({ creator: CURRENT_USER, - start_time: undefined, - end_time: undefined, calendar: [ { - uid: expect.any(String), + uid: 'entry-0', // id must stay! dtstart: { tzid: 'UTC', value: '20220101T000000' }, dtend: { tzid: 'UTC', value: '20220103T000000' }, rrule: 'FREQ=DAILY;COUNT=1', @@ -1608,9 +1703,15 @@ describe('test relevant functionality of MeetingService', () => { type: StateEventName.NIC_MEETINGS_METADATA_EVENT as string, ...stateEventStub, content: { - start_time: '', - end_time: '', creator: CURRENT_USER, + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'UTC', value: '20220101T000000' }, + dtend: { tzid: 'UTC', value: '20220103T000000' }, + rrule: undefined, + }, + ], }, }; @@ -1915,7 +2016,7 @@ describe('test relevant functionality of MeetingService', () => { end_time: undefined, calendar: [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, rrule: 'FREQ=DAILY;COUNT=3', @@ -1935,17 +2036,14 @@ describe('test relevant functionality of MeetingService', () => { )?.content as MeetingCreateDto; expect(nicMetadataEventContent).toStrictEqual({ creator: CURRENT_USER, - start_time: undefined, - end_time: undefined, calendar: [ { - uid: 'uid-0', + uid: 'entry-0', dtstart: { tzid: 'UTC', value: '20220201T000000' }, dtend: { tzid: 'UTC', value: '20220203T000000' }, rrule: 'FREQ=DAILY;COUNT=3', }, ], - auto_deletion_offset: undefined, force_deletion_at: new Date('2022-02-05T01:00:00Z').getTime(), external_data: undefined, }); @@ -1983,11 +2081,15 @@ describe('test relevant functionality of MeetingService', () => { )?.content, ).toStrictEqual({ creator: CURRENT_USER, - start_time: startTime, - end_time: endTime, - calendar: undefined, - auto_deletion_offset: 5, - force_deletion_at: undefined, + calendar: [ + { + uid: expect.any(String), + dtstart: { tzid: 'UTC', value: '20220101T000000' }, + dtend: { tzid: 'UTC', value: '20220103T000000' }, + rrule: undefined, + }, + ], + force_deletion_at: new Date('2022-01-03T00:05:00Z').getTime(), external_data: externalDataOx, } as IMeetingsMetadataEventContent); @@ -2014,8 +2116,6 @@ describe('test relevant functionality of MeetingService', () => { )?.content, ).toStrictEqual({ creator: CURRENT_USER, - start_time: undefined, - end_time: undefined, calendar: [ { uid: expect.any(String), @@ -2024,7 +2124,6 @@ describe('test relevant functionality of MeetingService', () => { rrule: 'FREQ=DAILY;COUNT=1', }, ], - auto_deletion_offset: undefined, force_deletion_at: new Date('2022-01-03T00:05:00Z').getTime(), external_data: externalDataOx1, } as IMeetingsMetadataEventContent); @@ -2048,8 +2147,8 @@ describe('test relevant functionality of MeetingService', () => { const metadata = room.roomEventsByName( StateEventName.NIC_MEETINGS_METADATA_EVENT, )[0]?.content as IMeetingsMetadataEventContent; - expect(metadata.auto_deletion_offset).toStrictEqual( - appConfig.auto_deletion_offset, + expect(metadata.force_deletion_at).toStrictEqual( + new Date('2022-11-11T14:12:21Z').getTime(), ); }); diff --git a/matrix-meetings-bot/test/RoomEventsBuilder.ts b/matrix-meetings-bot/test/RoomEventsBuilder.ts index 652d16af..4705489e 100644 --- a/matrix-meetings-bot/test/RoomEventsBuilder.ts +++ b/matrix-meetings-bot/test/RoomEventsBuilder.ts @@ -151,9 +151,14 @@ export class RoomEventsBuilder { room_id: this.room_id, sender: this.creator, content: { - start_time: '2021-05-11T10:45:00.000Z', - end_time: '2021-05-11T11:45:00.000Z', - auto_deletion_offset: 5, + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'UTC', value: '20210511T104500' }, + dtend: { tzid: 'UTC', value: '20210511T114500' }, + }, + ], + force_deletion_at: new Date('2021-05-11T11:50:00Z').getTime(), }, event_id: uuiv4(), user_id: this.creator, diff --git a/matrix-meetings-bot/test/RoomMessageService.test.ts b/matrix-meetings-bot/test/RoomMessageService.test.ts index c832977c..201c07e7 100644 --- a/matrix-meetings-bot/test/RoomMessageService.test.ts +++ b/matrix-meetings-bot/test/RoomMessageService.test.ts @@ -29,10 +29,12 @@ import { EventContentRenderer } from '../src/EventContentRenderer'; import { IAppConfiguration } from '../src/IAppConfiguration'; import { MatrixEndpoint } from '../src/MatrixEndpoint'; import { MeetingClient } from '../src/client/MeetingClient'; +import { CalendarEntryDto } from '../src/dto/CalendarEntryDto'; import { IMeeting } from '../src/model/IMeeting'; import { IUserContext } from '../src/model/IUserContext'; import { MeetingType } from '../src/model/MeetingType'; import { RoomMessageService } from '../src/service/RoomMessageService'; +import { mockCalendarEntry } from '../src/testUtils'; import { meetingChangesHelper } from '../src/util/IMeetingChanges'; import { createAppConfig } from './util/MockUtils'; @@ -61,30 +63,31 @@ describe('test RoomMessageService', () => { invite: {}, }, }; - const initMeeting = (rrule?: string) => { - const meeting: IMeeting = { + + function mockMeeting(calendar?: CalendarEntryDto[]): IMeeting { + return { roomId, title: 'title', description: 'description', - startTime: '2022-01-16T22:07:21.488Z', - endTime: '3022-12-16T22:07:21.488Z', - calendar: rrule - ? [ - { - uid: 'uuid', - dtstart: { tzid: 'UTC', value: '20220116T220721' }, - dtend: { tzid: 'UTC', value: '30221216T220721' }, - rrule, - }, - ] - : undefined, + calendar: calendar ?? mockCalendar(), widgetIds: [], participants: [], creator: 'creator', type: MeetingType.MEETING, }; - return meeting; - }; + } + + function mockCalendar(rrule?: string): CalendarEntryDto[] { + return [ + { + uid: 'uuid', + dtstart: { tzid: 'UTC', value: '20220116T090000' }, + dtend: { tzid: 'UTC', value: '20220116T100000' }, + rrule, + }, + ]; + } + const userContext: IUserContext = { locale: 'en', timezone: 'UTC', @@ -158,8 +161,8 @@ describe('test RoomMessageService', () => { }); it('test do not send a change-message if no values inside the meeting are changed', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting(); + const oldMeeting = mockMeeting(mockCalendar()); + const newMeeting = mockMeeting(mockCalendar()); const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -175,8 +178,8 @@ describe('test RoomMessageService', () => { }); it('test to send a message, that contains information about the title change, if title of meeting is changed', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting(); + const oldMeeting = mockMeeting(); + const newMeeting = mockMeeting(); oldMeeting.title = 'changed'; const meetingChanges = meetingChangesHelper.calculate( oldMeeting, @@ -195,11 +198,12 @@ describe('test RoomMessageService', () => { expect(msg).toContain('previously: changed'); expect(msg).not.toContain('Description: description'); expect(msg).not.toContain('Repeat meeting'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the description change, if description of meeting is changed', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting(); + const oldMeeting = mockMeeting(); + const newMeeting = mockMeeting(); oldMeeting.description = 'changed'; const meetingChanges = meetingChangesHelper.calculate( oldMeeting, @@ -218,12 +222,15 @@ describe('test RoomMessageService', () => { expect(msg).toContain('Description: description'); expect(msg).toContain('previously: changed'); expect(msg).not.toContain('Repeat meeting'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the time-range change, if starttime of meeting is changed', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting(); - oldMeeting.startTime = '2024-01-16T22:07:21.488Z'; + const oldMeeting = mockMeeting(); + const newMeeting = mockMeeting(); + if (newMeeting.calendar) { + newMeeting.calendar[0].dtstart.value = '20220116T080000'; + } const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -237,21 +244,22 @@ describe('test RoomMessageService', () => { ); verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain('Date: January 16, 2022, 8:00 – 10:00 AM UTC'); expect(msg).toContain( - 'Date: 01/16/2022 10:07 PM UTC to 12/16/3022 10:07 PM UTC', - ); - expect(msg).toContain( - '(previously: 01/16/2024 10:07 PM UTC to 12/16/3022 10:07 PM UTC)', + '(previously: January 16, 2022, 9:00 – 10:00 AM UTC)', ); expect(msg).not.toContain('Title: title'); expect(msg).not.toContain('Description: description'); expect(msg).not.toContain('Repeat meeting'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the time-range change, if endtime of meeting is changed', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting(); - oldMeeting.endTime = '3024-12-16T22:07:21.488Z'; + const oldMeeting = mockMeeting(); + const newMeeting = mockMeeting(); + if (newMeeting.calendar) { + newMeeting.calendar[0].dtend.value = '20220116T110000'; + } const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -265,20 +273,19 @@ describe('test RoomMessageService', () => { ); verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain('Date: January 16, 2022, 9:00 – 11:00 AM UTC'); expect(msg).toContain( - 'Date: 01/16/2022 10:07 PM UTC to 12/16/3022 10:07 PM UTC', - ); - expect(msg).toContain( - '(previously: 01/16/2022 10:07 PM UTC to 12/16/3024 10:07 PM UTC)', + '(previously: January 16, 2022, 9:00 – 10:00 AM UTC)', ); expect(msg).not.toContain('Title: title'); expect(msg).not.toContain('Description: description'); expect(msg).not.toContain('Repeat meeting'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the repetition change, if repetition is added', async () => { - const oldMeeting = initMeeting(); - const newMeeting = initMeeting('FREQ=MONTHLY'); + const oldMeeting = mockMeeting(mockCalendar()); + const newMeeting = mockMeeting(mockCalendar('FREQ=MONTHLY')); const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -297,11 +304,12 @@ describe('test RoomMessageService', () => { expect(msg).not.toContain('Title: title'); expect(msg).not.toContain('Date:'); expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the repetition change, if repetition is updated', async () => { - const oldMeeting = initMeeting('FREQ=DAILY'); - const newMeeting = initMeeting('FREQ=MONTHLY'); + const oldMeeting = mockMeeting(mockCalendar('FREQ=DAILY')); + const newMeeting = mockMeeting(mockCalendar('FREQ=MONTHLY')); const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -320,11 +328,12 @@ describe('test RoomMessageService', () => { expect(msg).not.toContain('Title: title'); expect(msg).not.toContain('Date:'); expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('A single meeting from a meeting series'); }); it('test to send a message, that contains information about the repetition change, if repetition is deleted', async () => { - const oldMeeting = initMeeting('FREQ=MONTHLY'); - const newMeeting = initMeeting(); + const oldMeeting = mockMeeting(mockCalendar('FREQ=MONTHLY')); + const newMeeting = mockMeeting(mockCalendar()); const meetingChanges = meetingChangesHelper.calculate( oldMeeting, newMeeting, @@ -343,5 +352,196 @@ describe('test RoomMessageService', () => { expect(msg).not.toContain('Title: title'); expect(msg).not.toContain('Date:'); expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('A single meeting from a meeting series'); + }); + + it('test to send a message, that contains information about occurrence change when: add an updated occurrence to a single recurring meeting', async () => { + const oldMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]); + const newMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]); + const meetingChanges = meetingChangesHelper.calculate( + oldMeeting, + newMeeting, + ); + await roomMessageService.notifyMeetingTimeChangedAsync( + userContext, + oldMeeting, + newMeeting, + meetingChanges, + roomId, + ); + verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); + const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain( + 'A single meeting from a meeting series is moved to January 11, 2020, 12:00 – 1:00 PM UTC', + ); + expect(msg).toContain( + '(previously: January 11, 2020, 10:00 – 11:00 AM UTC)', + ); + expect(msg).not.toContain('Title: title'); + expect(msg).not.toContain('Date:'); + expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('Repeat meeting'); + }); + + it('test to send a message, that contains information about occurrence change when: update a single occurrence of a recurring meeting', async () => { + const oldMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200111T103000', + dtend: '20200111T113000', + recurrenceId: '20200111T100000', + }), + + // another override that should stay + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + ]); + const newMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200115T103000', + dtend: '20200115T113000', + recurrenceId: '20200115T100000', + }), + mockCalendarEntry({ + dtstart: '20200111T120000', + dtend: '20200111T130000', + recurrenceId: '20200111T100000', + }), + ]); + const meetingChanges = meetingChangesHelper.calculate( + oldMeeting, + newMeeting, + ); + await roomMessageService.notifyMeetingTimeChangedAsync( + userContext, + oldMeeting, + newMeeting, + meetingChanges, + roomId, + ); + verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); + const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain( + 'A single meeting from a meeting series is moved to January 11, 2020, 12:00 – 1:00 PM UTC', + ); + expect(msg).toContain( + '(previously: January 11, 2020, 10:30 – 11:30 AM UTC)', + ); + expect(msg).not.toContain('Title: title'); + expect(msg).not.toContain('Date:'); + expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('Repeat meeting'); + }); + + it('test to send a message, that contains information about occurrence change when: delete an occurrence of a meeting', async () => { + const oldMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + ]); + const newMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200215T100000'], + }), + ]); + const meetingChanges = meetingChangesHelper.calculate( + oldMeeting, + newMeeting, + ); + await roomMessageService.notifyMeetingTimeChangedAsync( + userContext, + oldMeeting, + newMeeting, + meetingChanges, + roomId, + ); + verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); + const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain( + 'A single meeting from a meeting series on February 15, 2020, 10:00 – 11:00 AM UTC is deleted', + ); + expect(msg).not.toContain('(previously:'); + expect(msg).not.toContain('Title: title'); + expect(msg).not.toContain('Date:'); + expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('Repeat meeting'); + }); + + it('test to send a message, that contains information about occurrence change when: delete existing overrides', async () => { + const oldMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + }), + mockCalendarEntry({ + dtstart: '20200110T103000', + dtend: '20200110T113000', + recurrenceId: '20200110T100000', + }), + ]); + const newMeeting = mockMeeting([ + mockCalendarEntry({ + dtstart: '20200109T100000', + dtend: '20200109T110000', + rrule: 'FREQ=DAILY', + exdate: ['20200110T100000'], + }), + ]); + const meetingChanges = meetingChangesHelper.calculate( + oldMeeting, + newMeeting, + ); + await roomMessageService.notifyMeetingTimeChangedAsync( + userContext, + oldMeeting, + newMeeting, + meetingChanges, + roomId, + ); + verify(matrixClientMock.sendHtmlText(anyString(), anything())).times(1); + const msg = capture(matrixClientMock.sendHtmlText).first()[1]; + expect(msg).toContain( + 'A single meeting from a meeting series on January 10, 2020, 10:30 – 11:30 AM UTC is deleted', + ); + expect(msg).not.toContain('(previously:'); + expect(msg).not.toContain('Title: title'); + expect(msg).not.toContain('Date:'); + expect(msg).not.toContain('Description: description'); + expect(msg).not.toContain('Repeat meeting'); }); }); diff --git a/matrix-meetings-bot/test/util/IMeetingChanges.test.ts b/matrix-meetings-bot/test/util/IMeetingChanges.test.ts index 9f2d9a01..b5f76e09 100644 --- a/matrix-meetings-bot/test/util/IMeetingChanges.test.ts +++ b/matrix-meetings-bot/test/util/IMeetingChanges.test.ts @@ -27,8 +27,13 @@ describe('test IMeetingChanges', () => { roomId: 'r1', title: 'title', description: 'description', - startTime: '2022-01-01T00:00:00.000Z', - endTime: '2022-01-01T00:00:00.000Z', + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + }, + ], widgetIds: [], participants: [], creator: 'creator', @@ -38,10 +43,7 @@ describe('test IMeetingChanges', () => { const nothingChanged: IMeetingChanges = { titleChanged: false, descriptionChanged: false, - startTimeChanged: false, - endTimeChanged: false, - calendarChanged: false, - timeChanged: false, + calendarChanges: [], anythingChanged: false, }; expect( @@ -55,12 +57,30 @@ describe('test IMeetingChanges', () => { expect( meetingChangesHelper.calculate(meeting, { ...meeting, - startTime: 'some time', + calendar: [ + { + uid: 'entry-0', + dtstart: { tzid: 'Europe/Berlin', value: '20211231T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + }, + ], }), ).toEqual({ ...nothingChanged, - startTimeChanged: true, - timeChanged: true, + calendarChanges: [ + { + changeType: 'updateSingleOrRecurringTime', + uid: 'entry-0', + oldValue: { + dtstart: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + }, + newValue: { + dtstart: { tzid: 'Europe/Berlin', value: '20211231T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + }, + }, + ], anythingChanged: true, } as IMeetingChanges); @@ -69,7 +89,7 @@ describe('test IMeetingChanges', () => { ...meeting, calendar: [ { - uid: 'uuid', + uid: 'entry-0', dtstart: { tzid: 'Europe/Berlin', value: '20200101T000000' }, dtend: { tzid: 'Europe/Berlin', value: '20200101T000000' }, rrule: 'FREQ=DAILY', @@ -78,7 +98,26 @@ describe('test IMeetingChanges', () => { }), ).toEqual({ ...nothingChanged, - calendarChanged: true, + calendarChanges: [ + { + changeType: 'updateSingleOrRecurringTime', + uid: 'entry-0', + oldValue: { + dtstart: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20220101T000000' }, + }, + newValue: { + dtstart: { tzid: 'Europe/Berlin', value: '20200101T000000' }, + dtend: { tzid: 'Europe/Berlin', value: '20200101T000000' }, + }, + }, + { + changeType: 'updateSingleOrRecurringRrule', + uid: 'entry-0', + oldValue: undefined, + newValue: 'FREQ=DAILY', + }, + ], anythingChanged: true, } as IMeetingChanges); }); diff --git a/matrix-meetings-bot/test/util/TemplateHelper.test.ts b/matrix-meetings-bot/test/util/TemplateHelper.test.ts index 7de2edd2..f7c4f272 100644 --- a/matrix-meetings-bot/test/util/TemplateHelper.test.ts +++ b/matrix-meetings-bot/test/util/TemplateHelper.test.ts @@ -14,29 +14,30 @@ * limitations under the License. */ -import { CalendarEntryDto } from '../../src/dto/CalendarEntryDto'; -import { getMeetingEndTime, getMeetingStartTime } from '../../src/shared'; import { InviteParams, templateHelper } from '../../src/util/TemplateHelper'; describe('TemplateHelper', () => { - const calendar: CalendarEntryDto[] = [ - { - uid: 'uid-0', - dtstart: { tzid: 'UTC', value: '20201111T140700' }, - dtend: { tzid: 'UTC', value: '20201111T160700' }, - rrule: 'FREQ=DAILY;COUNT=3', - }, - ]; - const demoMeeting: InviteParams = { description: 'A demo meeting', - startTime: getMeetingStartTime(undefined, calendar), - endTime: getMeetingEndTime(undefined, calendar), + calendar: [ + { + uid: 'uid-0', + dtstart: { tzid: 'UTC', value: '20201111T140700' }, + dtend: { tzid: 'UTC', value: '20201111T160700' }, + }, + ], }; const demoMeetingRecurring: InviteParams = { - ...demoMeeting, - calendar, + description: 'A demo meeting', + calendar: [ + { + uid: 'uid-0', + dtstart: { tzid: 'UTC', value: '20201111T140700' }, + dtend: { tzid: 'UTC', value: '20201111T160700' }, + rrule: 'FREQ=DAILY;COUNT=3', + }, + ], }; test('invite message to member en', () => { diff --git a/yarn.lock b/yarn.lock index 70cd33e2..3f1b8ede 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9316,18 +9316,6 @@ mktemp@~0.4.0: resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.4.0.tgz#6d0515611c8a8c84e484aa2000129b98e981ff0b" integrity sha1-bQUVYRyKjITkhKogABKbmOmB/ws= -moment-timezone@^0.5.43: - version "0.5.43" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.43.tgz#3dd7f3d0c67f78c23cd1906b9b2137a09b3c4790" - integrity sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ== - dependencies: - moment "^2.29.4" - -moment@^2.29.4: - version "2.29.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== - moo@^0.5.0, moo@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"