diff --git a/services/calendar/pod-calendar/src/__tests__/conference.test.ts b/services/calendar/pod-calendar/src/__tests__/conference.test.ts new file mode 100644 index 00000000000..063d947d89d --- /dev/null +++ b/services/calendar/pod-calendar/src/__tests__/conference.test.ts @@ -0,0 +1,67 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { getConferenceLink, getEventLocation } from '../conference' + +const meet = 'https://meet.google.com/abc-defg-hij' + +describe('getConferenceLink', () => { + it('returns hangoutLink', () => { + expect(getConferenceLink({ hangoutLink: meet })).toBe(meet) + }) + + it('falls back to the video entry point of conferenceData', () => { + expect( + getConferenceLink({ + conferenceData: { + entryPoints: [ + { entryPointType: 'phone', uri: 'tel:+1-555-0100' }, + { entryPointType: 'video', uri: meet } + ] + } + }) + ).toBe(meet) + }) + + it('returns undefined without conference', () => { + expect(getConferenceLink({})).toBeUndefined() + expect(getConferenceLink({ conferenceData: { entryPoints: [{ entryPointType: 'phone', uri: 'tel:1' }] } })).toBe( + undefined + ) + }) +}) + +describe('getEventLocation', () => { + it('keeps an explicit location', () => { + expect(getEventLocation({ location: 'Room 1', hangoutLink: meet })).toBe('Room 1') + }) + + it('uses the conference link when location is missing or empty', () => { + expect(getEventLocation({ hangoutLink: meet })).toBe(meet) + expect(getEventLocation({ location: '', hangoutLink: meet })).toBe(meet) + expect(getEventLocation({ location: ' ', hangoutLink: meet })).toBe(meet) + }) + + it('is stable on repeated calls', () => { + const event = { hangoutLink: meet } + expect(getEventLocation(event)).toBe(getEventLocation(event)) + }) + + it('keeps previous behaviour without conference', () => { + expect(getEventLocation({})).toBeUndefined() + expect(getEventLocation({ location: null })).toBeUndefined() + expect(getEventLocation({ location: '' })).toBe('') + }) +}) diff --git a/services/calendar/pod-calendar/src/calendar.ts b/services/calendar/pod-calendar/src/calendar.ts index af7647b68c7..c07e50800ac 100644 --- a/services/calendar/pod-calendar/src/calendar.ts +++ b/services/calendar/pod-calendar/src/calendar.ts @@ -26,6 +26,7 @@ import { areEqualMarkups, htmlToMarkup, isEmptyMarkup, jsonToHTML, markupToJSON import { deepEqual } from 'fast-equals' import { OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' +import { getEventLocation } from './conference' import { getRateLimitter, RateLimiter } from './rateLimiter' import { type Token } from './types' import { @@ -358,7 +359,7 @@ export class CalendarClient { }) event.description = description } - if (current.location !== event.location) { + if (current.location !== getEventLocation(event)) { res = true this.ctx.info('Update event diff: location', { calendarId: current.calendar, diff --git a/services/calendar/pod-calendar/src/conference.ts b/services/calendar/pod-calendar/src/conference.ts new file mode 100644 index 00000000000..c493f6fc6f1 --- /dev/null +++ b/services/calendar/pod-calendar/src/conference.ts @@ -0,0 +1,40 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 type { calendar_v3 } from 'googleapis' + +/** + * Returns the video conference link (e.g. Google Meet) attached to a Google event, if any. + */ +export function getConferenceLink (event: calendar_v3.Schema$Event): string | undefined { + if (event.hangoutLink != null && event.hangoutLink !== '') { + return event.hangoutLink + } + const entryPoint = event.conferenceData?.entryPoints?.find( + (p) => p.entryPointType === 'video' && p.uri != null && p.uri !== '' + ) + return entryPoint?.uri ?? undefined +} + +/** + * Returns the location to store in Huly for a Google event. + * An explicit location always wins; the conference link is used only when the location is empty. + */ +export function getEventLocation (event: calendar_v3.Schema$Event): string | undefined { + if (event.location != null && event.location.trim() !== '') { + return event.location + } + return getConferenceLink(event) ?? event.location ?? undefined +} diff --git a/services/calendar/pod-calendar/src/outcomingClient.ts b/services/calendar/pod-calendar/src/outcomingClient.ts index a88e957cd74..2afe1da5faf 100644 --- a/services/calendar/pod-calendar/src/outcomingClient.ts +++ b/services/calendar/pod-calendar/src/outcomingClient.ts @@ -28,6 +28,7 @@ import { deepEqual } from 'fast-equals' import { OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' import { getClient } from './client' +import { getEventLocation } from './conference' import { setSyncHistory } from './kvsUtils' import { lock, synced } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' @@ -276,7 +277,7 @@ export class OutcomingClient { }) event.description = description } - if (current.location !== event.location) { + if (current.location !== getEventLocation(event)) { res = true this.ctx.info('Update event diff: location', { calendarId: current.calendar, diff --git a/services/calendar/pod-calendar/src/sync.ts b/services/calendar/pod-calendar/src/sync.ts index ec42c5d2580..2b89f8077d5 100644 --- a/services/calendar/pod-calendar/src/sync.ts +++ b/services/calendar/pod-calendar/src/sync.ts @@ -44,6 +44,7 @@ import { htmlToMarkup } from '@hcengineering/text' import { deepEqual } from 'fast-equals' import { calendar_v3 } from 'googleapis' import { getClient } from './client' +import { getEventLocation } from './conference' import { getCalendarsSyncHistory, getEventHistory, setCalendarsSyncHistory, setEventHistory } from './kvsUtils' import { lock } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' @@ -401,7 +402,7 @@ export class IncomingSyncManager { allDay: event.start?.date != null, description: htmlToMarkup(event.description ?? ''), title: event.summary ?? '', - location: event.location ?? undefined, + location: getEventLocation(event), participants: participants[0], eventId: event.id ?? '', calendar: _calendar, @@ -486,8 +487,9 @@ export class IncomingSyncManager { res.externalParticipants = participants[1] } } - if (event.location != null) { - res.location = event.location + const location = getEventLocation(event) + if (location != null) { + res.location = location } if (event.description != null) { res.description = htmlToMarkup(event.description)