diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7479d88a11..a63b7bfbcb 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -736,6 +736,9 @@ importers: '@rush-temp/pod-calendar': specifier: file:./projects/pod-calendar.tgz version: file:projects/pod-calendar.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4) + '@rush-temp/pod-calendar-mailer': + specifier: file:./projects/pod-calendar-mailer.tgz + version: file:projects/pod-calendar-mailer.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9)) '@rush-temp/pod-collaborator': specifier: file:./projects/pod-collaborator.tgz version: file:projects/pod-collaborator.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9)) @@ -4753,6 +4756,10 @@ packages: resolution: {integrity: sha512-Ccg90DAJu+vNRMm00Z8W768WT8M6AKzjUFktH9/RBbDGQSF+UYQNm7Ah/cMJnJ0GMOg/dQ//r7Tob7WL4kaBeg==, tarball: file:projects/pod-backup.tgz} version: 0.0.0 + '@rush-temp/pod-calendar-mailer@file:projects/pod-calendar-mailer.tgz': + resolution: {integrity: sha512-kMD847OG0mBREUKvdiUEQsrhDLyd4ZRcku/7T0HTPXwfEudiYb4ZjOWJ4EjTtCRwqO4vQJVkF4xppzthAqWn6Q==, tarball: file:projects/pod-calendar-mailer.tgz} + version: 0.0.0 + '@rush-temp/pod-calendar@file:projects/pod-calendar.tgz': resolution: {integrity: sha512-ZZGuUNNBPQ8AuNUoq5jWopHtN7BExJBYPaEv+CULuyunTRzYli3xe/AKDM6pYQ/gR4ePMrPjnqcOeC3MBCeXUA==, tarball: file:projects/pod-calendar.tgz} version: 0.0.0 @@ -21525,6 +21532,37 @@ snapshots: - node-notifier - supports-color + '@rush-temp/pod-calendar-mailer@file:projects/pod-calendar-mailer.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))': + dependencies: + '@tsconfig/node16': 1.0.4 + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3) + cross-env: 7.0.3 + dotenv: 16.0.3 + esbuild: 0.24.2 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.7.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-node: 11.1.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) + prettier: 3.2.5 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3) + ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - '@swc/core' + - '@swc/wasm' + - babel-jest + - babel-plugin-macros + - node-notifier + - supports-color + '@rush-temp/pod-calendar@file:projects/pod-calendar.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4)': dependencies: '@tsconfig/node16': 1.0.4 diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 73064492e1..4ac6a2cfed 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -358,6 +358,10 @@ export function createModel (builder: Builder): void { { state: 1 } ] }) + + builder.mixin(calendar.class.Event, core.class.Class, view.mixin.ObjectTitle, { + titleProvider: calendar.function.EventTitleProvider + }) } export default calendar diff --git a/plugins/calendar-assets/lang/cs.json b/plugins/calendar-assets/lang/cs.json index 56247a9f2f..2e5fcb6d38 100644 --- a/plugins/calendar-assets/lang/cs.json +++ b/plugins/calendar-assets/lang/cs.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Server CalDAV", "CalDavAccessAccount": "Účet Huly", "CalDavAccessPassword": "Heslo", - "CalDavAccessPasswordWarning": "Tohle heslo už nebudete moci znovu vidět. Uložte si ho na bezpečné místo." + "CalDavAccessPasswordWarning": "Tohle heslo už nebudete moci znovu vidět. Uložte si ho na bezpečné místo.", + "MeetingScheduledNotification": "Naplánována schůzka", + "MeetingRescheduledNotification": "Čas schůzky změněn", + "MeetingCanceledNotification": "Schůzka zrušena: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/de.json b/plugins/calendar-assets/lang/de.json index 7842d121d6..9415cab7e4 100644 --- a/plugins/calendar-assets/lang/de.json +++ b/plugins/calendar-assets/lang/de.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "CalDAV-Server", "CalDavAccessAccount": "Huly-Konto", "CalDavAccessPassword": "Passwort", - "CalDavAccessPasswordWarning": "Sie können dieses Passwort nicht erneut anzeigen. Bitte speichern Sie es an einem sicheren Ort." + "CalDavAccessPasswordWarning": "Sie können dieses Passwort nicht erneut anzeigen. Bitte speichern Sie es an einem sicheren Ort.", + "MeetingScheduledNotification": "Besprechung geplant", + "MeetingRescheduledNotification": "Besprechungszeit geändert", + "MeetingCanceledNotification": "Besprechung abgesagt: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/en.json b/plugins/calendar-assets/lang/en.json index b4d154402f..b044a87182 100644 --- a/plugins/calendar-assets/lang/en.json +++ b/plugins/calendar-assets/lang/en.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "CalDAV server", "CalDavAccessAccount": "Huly account", "CalDavAccessPassword": "Password", - "CalDavAccessPasswordWarning": "You will not be able to see this password again. Please save it in a secure place." + "CalDavAccessPasswordWarning": "You will not be able to see this password again. Please save it in a secure place.", + "MeetingScheduledNotification": "Meeting scheduled", + "MeetingRescheduledNotification": "Meeting time changed", + "MeetingCanceledNotification": "Meeting canceled: {title}" } } diff --git a/plugins/calendar-assets/lang/es.json b/plugins/calendar-assets/lang/es.json index c1bc944a59..b8917fed0f 100644 --- a/plugins/calendar-assets/lang/es.json +++ b/plugins/calendar-assets/lang/es.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Servidor CalDAV", "CalDavAccessAccount": "Cuenta de Huly", "CalDavAccessPassword": "Contraseña", - "CalDavAccessPasswordWarning": "No podrá ver esta contraseña nuevamente. Guárdela en un lugar seguro." + "CalDavAccessPasswordWarning": "No podrá ver esta contraseña nuevamente. Guárdela en un lugar seguro.", + "MeetingScheduledNotification": "Reunión programada", + "MeetingRescheduledNotification": "Se modificó la hora de la reunión", + "MeetingCanceledNotification": "Reunión cancelada: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/fr.json b/plugins/calendar-assets/lang/fr.json index 979b5ce701..d51f7f9b8b 100644 --- a/plugins/calendar-assets/lang/fr.json +++ b/plugins/calendar-assets/lang/fr.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Serveur CalDAV", "CalDavAccessAccount": "Compte Huly", "CalDavAccessPassword": "Mot de passe", - "CalDavAccessPasswordWarning": "Vous ne pourrez plus voir ce mot de passe. Veuillez le conserver dans un endroit sûr." + "CalDavAccessPasswordWarning": "Vous ne pourrez plus voir ce mot de passe. Veuillez le conserver dans un endroit sûr.", + "MeetingScheduledNotification": "Réunion planifiée", + "MeetingRescheduledNotification": "Heure de la réunion modifiée", + "MeetingCanceledNotification": "Réunion annulée: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/it.json b/plugins/calendar-assets/lang/it.json index abf5dc4ccb..f46d4beeea 100644 --- a/plugins/calendar-assets/lang/it.json +++ b/plugins/calendar-assets/lang/it.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Server CalDAV", "CalDavAccessAccount": "Account Huly", "CalDavAccessPassword": "Password", - "CalDavAccessPasswordWarning": "Non potrai vedere di nuovo questa password. Conservala in un luogo sicuro." + "CalDavAccessPasswordWarning": "Non potrai vedere di nuovo questa password. Conservala in un luogo sicuro.", + "MeetingScheduledNotification": "Riunione pianificata", + "MeetingRescheduledNotification": "Orario della riunione modificato", + "MeetingCanceledNotification": "Riunione annullata: {title}" } } diff --git a/plugins/calendar-assets/lang/pt.json b/plugins/calendar-assets/lang/pt.json index 55f6394284..449e6e388e 100644 --- a/plugins/calendar-assets/lang/pt.json +++ b/plugins/calendar-assets/lang/pt.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Servidor CalDAV", "CalDavAccessAccount": "Conta Huly", "CalDavAccessPassword": "Password", - "CalDavAccessPasswordWarning": "Não será possível ver esta palavra-passe novamente. Guarde-a num local seguro." + "CalDavAccessPasswordWarning": "Não será possível ver esta palavra-passe novamente. Guarde-a num local seguro.", + "MeetingScheduledNotification": "Reunião agendada", + "MeetingRescheduledNotification": "Horário da reunião alterado", + "MeetingCanceledNotification": "Reunião cancelada: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/ru.json b/plugins/calendar-assets/lang/ru.json index 6fbf5f7b74..a8e525acec 100644 --- a/plugins/calendar-assets/lang/ru.json +++ b/plugins/calendar-assets/lang/ru.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "Сервер CalDAV", "CalDavAccessAccount": "Учетная запись Huly", "CalDavAccessPassword": "Пароль", - "CalDavAccessPasswordWarning": "Вы не сможете увидеть этот пароль снова. Пожалуйста, сохраните его в безопасном месте." + "CalDavAccessPasswordWarning": "Вы не сможете увидеть этот пароль снова. Пожалуйста, сохраните его в безопасном месте.", + "MeetingScheduledNotification": "Встреча запланирована", + "MeetingRescheduledNotification": "Время встречи изменено", + "MeetingCanceledNotification": "Встреча отменена: {title}" } } diff --git a/plugins/calendar-assets/lang/zh.json b/plugins/calendar-assets/lang/zh.json index 89ce74e1ff..1090bfd74b 100644 --- a/plugins/calendar-assets/lang/zh.json +++ b/plugins/calendar-assets/lang/zh.json @@ -105,6 +105,9 @@ "CalDavAccessServer": "CalDAV 服务器", "CalDavAccessAccount": "Huly 帐户", "CalDavAccessPassword": "密码", - "CalDavAccessPasswordWarning": "您将无法再次看到此密码。请将其保存在安全的地方。" + "CalDavAccessPasswordWarning": "您将无法再次看到此密码。请将其保存在安全的地方。", + "MeetingScheduledNotification": "会议已安排", + "MeetingRescheduledNotification": "会议时间已更改", + "MeetingCanceledNotification": "会议已取消: {title}" } } \ No newline at end of file diff --git a/plugins/calendar-resources/src/components/CreateEvent.svelte b/plugins/calendar-resources/src/components/CreateEvent.svelte index 95b4bfb44b..4fec9931cc 100644 --- a/plugins/calendar-resources/src/components/CreateEvent.svelte +++ b/plugins/calendar-resources/src/components/CreateEvent.svelte @@ -310,7 +310,7 @@ padding-top: 0; } &:not(.rightCropPadding) { - padding: 0.75rem 1.25rem; + padding: 0.75rem 1rem; } &.rightCropPadding { padding: 0.75rem 1rem 0.75rem 1.25rem; diff --git a/plugins/calendar-resources/src/components/EditEvent.svelte b/plugins/calendar-resources/src/components/EditEvent.svelte index 33b499fe27..a3931fb3c4 100644 --- a/plugins/calendar-resources/src/components/EditEvent.svelte +++ b/plugins/calendar-resources/src/components/EditEvent.svelte @@ -279,6 +279,7 @@ } &.description { padding: 0 1.25rem; + min-height: 2.625rem; } } .top-icon { diff --git a/plugins/calendar-resources/src/components/EventReminders.svelte b/plugins/calendar-resources/src/components/EventReminders.svelte index 35c45c34bd..bee06f3b64 100644 --- a/plugins/calendar-resources/src/components/EventReminders.svelte +++ b/plugins/calendar-resources/src/components/EventReminders.svelte @@ -44,23 +44,30 @@ } -
- -
+{#if !disabled} +
+ +
+{/if} {#if reminders.length} + {#if disabled} +
+ +
+ {/if} {#if reminders.length > maxReminders && !shown} {#each reminders.slice(0, maxReminders - 2) as reminder, i} { - console.log('Selected', selectedId) if (selectedId !== undefined) { change(selectedId) } diff --git a/plugins/calendar-resources/src/index.ts b/plugins/calendar-resources/src/index.ts index 6a15f106ba..7a464eabfa 100644 --- a/plugins/calendar-resources/src/index.ts +++ b/plugins/calendar-resources/src/index.ts @@ -46,7 +46,7 @@ import ScheduleNavSection from './components/ScheduleNavSection.svelte' import calendar from './plugin' import contact from '@hcengineering/contact' import { deleteObjects } from '@hcengineering/view-resources' -import { configureCalDavAccess } from './utils' +import { eventTitleProvider, configureCalDavAccess } from './utils' export { EventElement, @@ -216,6 +216,7 @@ export default async (): Promise => ({ } }, function: { - ConfigureCalDavAccess: configureCalDavAccess + ConfigureCalDavAccess: configureCalDavAccess, + EventTitleProvider: eventTitleProvider } }) diff --git a/plugins/calendar-resources/src/utils.ts b/plugins/calendar-resources/src/utils.ts index ea49286e96..60c92b1bc2 100644 --- a/plugins/calendar-resources/src/utils.ts +++ b/plugins/calendar-resources/src/utils.ts @@ -5,7 +5,16 @@ import { type ReccuringInstance, generateEventId } from '@hcengineering/calendar' -import { type DocumentUpdate, type IdMap, type Timestamp, getCurrentAccount, toIdMap } from '@hcengineering/core' +import { + type Client, + type Doc, + type DocumentUpdate, + type IdMap, + type Ref, + type Timestamp, + getCurrentAccount, + toIdMap +} from '@hcengineering/core' import presentation, { createQuery, getClient, onClient } from '@hcengineering/presentation' import { closePopup, DAY, showPopup } from '@hcengineering/ui' import { writable } from 'svelte/store' @@ -210,3 +219,11 @@ export function getAccountClient (): AccountClient { return getAccountClientRaw(accountsUrl, token) } + +export async function eventTitleProvider (client: Client, ref: Ref, doc?: Event): Promise { + const object = doc ?? (await client.findOne(calendar.class.Event, { _id: ref as Ref })) + if (object === undefined) { + return '' + } + return object.title +} diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index a45dc65b77..88ba570b1f 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -1,4 +1,4 @@ -// Copyright © 2022 Hardcore Engineering Inc. +// Copyright © 2022-2025 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 @@ -12,7 +12,18 @@ // limitations under the License. import { Contact, Employee } from '@hcengineering/contact' -import type { AttachedDoc, Class, Doc, Markup, Mixin, PersonId, Ref, SystemSpace, Timestamp } from '@hcengineering/core' +import type { + AttachedDoc, + Class, + Client, + Doc, + Markup, + Mixin, + PersonId, + Ref, + SystemSpace, + Timestamp +} from '@hcengineering/core' import { NotificationType } from '@hcengineering/notification' import type { Asset, IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' @@ -246,7 +257,10 @@ const calendarPlugin = plugin(calendarId, { CalDavAccessServer: '' as IntlString, CalDavAccessAccount: '' as IntlString, CalDavAccessPassword: '' as IntlString, - CalDavAccessPasswordWarning: '' as IntlString + CalDavAccessPasswordWarning: '' as IntlString, + MeetingScheduledNotification: '' as IntlString, + MeetingRescheduledNotification: '' as IntlString, + MeetingCanceledNotification: '' as IntlString }, handler: { DisconnectHandler: '' as Handler @@ -267,7 +281,8 @@ const calendarPlugin = plugin(calendarId, { NoAttached: '' as Ref }, function: { - ConfigureCalDavAccess: '' as Resource<() => Promise> + ConfigureCalDavAccess: '' as Resource<() => Promise>, + EventTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise> } }) diff --git a/plugins/love-resources/src/components/MeetingData.svelte b/plugins/love-resources/src/components/MeetingData.svelte index ab99412e37..866a0c2630 100644 --- a/plugins/love-resources/src/components/MeetingData.svelte +++ b/plugins/love-resources/src/components/MeetingData.svelte @@ -46,6 +46,7 @@ kind={'ghost'} padding={'0 .5rem'} justify={'left'} + flex="1" on:click={() => { isMeeting = !isMeeting changeIsMeeting(isMeeting) diff --git a/rush.json b/rush.json index 232893af7a..878d27b1a4 100644 --- a/rush.json +++ b/rush.json @@ -2118,6 +2118,11 @@ "projectFolder": "services/calendar/pod-calendar", "shouldPublish": false }, + { + "packageName": "@hcengineering/pod-calendar-mailer", + "projectFolder": "services/calendar/pod-calendar-mailer", + "shouldPublish": false + }, { "packageName": "@hcengineering/qms-doc-import-tool", "projectFolder": "dev/doc-import-tool", diff --git a/server-plugins/calendar-resources/package.json b/server-plugins/calendar-resources/package.json index 2ba053fbe9..0cfd717937 100644 --- a/server-plugins/calendar-resources/package.json +++ b/server-plugins/calendar-resources/package.json @@ -43,6 +43,7 @@ "@hcengineering/server-token": "^0.6.11", "@hcengineering/calendar": "^0.6.24", "@hcengineering/contact": "^0.6.24", + "@hcengineering/kafka": "^0.6.0", "@hcengineering/server-core": "^0.6.1", "@hcengineering/server-notification-resources": "^0.6.0", "@hcengineering/server-contact": "^0.6.1" diff --git a/server-plugins/calendar-resources/src/index.ts b/server-plugins/calendar-resources/src/index.ts index 09cdf84e05..61e3d66e78 100644 --- a/server-plugins/calendar-resources/src/index.ts +++ b/server-plugins/calendar-resources/src/index.ts @@ -1,5 +1,5 @@ // -// Copyright © 2022 Hardcore Engineering Inc. +// Copyright © 2022-2025 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 @@ -38,7 +38,7 @@ import core, { } from '@hcengineering/core' import serverCalendar from '@hcengineering/server-calendar' import { getMetadata, getResource } from '@hcengineering/platform' -import { TriggerControl } from '@hcengineering/server-core' +import { QueueTopic, TriggerControl } from '@hcengineering/server-core' import { getPerson, getSocialStrings, getSocialIds } from '@hcengineering/server-contact' import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources' import { generateToken } from '@hcengineering/server-token' @@ -183,7 +183,7 @@ async function getEventPerson ( ): Promise | undefined> { const calendar = calendars.find((c) => c._id === current.calendar) if (calendar === undefined) return - const person = await getPerson(control, current.createdBy ?? current.modifiedBy) + const person = await getPerson(control, current.user ?? current.createdBy ?? current.modifiedBy) return person?._id } @@ -209,6 +209,7 @@ async function OnEvent (txes: Tx[], control: TriggerControl): Promise { async function onEventMixin (ctx: TxMixin, control: TriggerControl): Promise { const ops = ctx.attributes const event = (await control.findAll(control.ctx, calendar.class.Event, { _id: ctx.objectId }, { limit: 1 }))[0] + void putEventToQueue(control, 'mixin', event, ctx.modifiedBy, ops) if (event === undefined) return [] if (event.access !== 'owner') return [] const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId }) @@ -237,6 +238,7 @@ async function onEventUpdate (ctx: TxUpdateDoc, control: TriggerControl): if (ctx.modifiedBy !== core.account.System) { void sendEventToService(event, 'update', control) } + void putEventToQueue(control, 'update', event, ctx.modifiedBy, ops) if (event.access !== 'owner') return [] const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId }) const res: Tx[] = [] @@ -287,7 +289,7 @@ async function eventForNewParticipants ( const socialIds = await getSocialIds(control, part) if (socialIds.length === 0) continue const socialStrings = socialIds.map((si) => si._id) - if (socialStrings.includes(event.createdBy ?? event.modifiedBy)) continue + if (socialStrings.includes(event.user ?? event.createdBy ?? event.modifiedBy)) continue const primarySocialString = pickPrimarySocialId(socialIds)._id const user = primarySocialString @@ -299,7 +301,7 @@ async function eventForNewParticipants ( { ...data, calendar, access, user }, undefined, undefined, - primarySocialString + event.modifiedBy ) const outerTx = control.txFactory.createTxCollectionCUD( attachedToClass, @@ -308,7 +310,7 @@ async function eventForNewParticipants ( collection, innerTx, undefined, - primarySocialString + event.modifiedBy ) res.push(outerTx) } @@ -347,11 +349,43 @@ async function sendEventToService ( } } +type EventCUDType = 'create' | 'update' | 'delete' | 'mixin' +interface EventCUDMessage { + action: EventCUDType + event: Event + modifiedBy: PersonId + changes?: Record +} + +async function putEventToQueue ( + control: TriggerControl, + action: EventCUDType, + event: Event, + modifiedBy: PersonId, + changes?: Record +): Promise { + if (control.queue === undefined) { + return + } + const producer = control.queue.createProducer( + control.ctx.newChild('queue', {}), + QueueTopic.CalendarEventCUD + ) + try { + await producer.send(control.workspace.uuid, [{ action, event, modifiedBy, changes }]) + } catch (err) { + control.ctx.error('Could not queue calendar event', { err, action, event }) + } finally { + await producer.close() + } +} + async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): Promise { const event = TxProcessor.createDoc2Doc(ctx) if (ctx.modifiedBy !== core.account.System) { void sendEventToService(event, 'create', control) } + void putEventToQueue(control, 'create', event, ctx.modifiedBy) if (event.access !== 'owner') return [] const res: Tx[] = [] const { _class, space, attachedTo, attachedToClass, collection, ...attr } = event @@ -362,7 +396,7 @@ async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): const socialIds = await getSocialIds(control, part as Ref) if (socialIds.length === 0) continue const socialStrings = socialIds.map((si) => si._id) - if (socialStrings.includes(event.createdBy ?? event.modifiedBy)) continue + if (socialStrings.includes(event.user ?? event.createdBy ?? event.modifiedBy)) continue const primarySocialString = pickPrimarySocialId(socialIds)._id const user = primarySocialString const calendar = getCalendar(calendars, socialStrings) @@ -373,7 +407,7 @@ async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): { ...data, calendar, access, user }, undefined, undefined, - primarySocialString + ctx.modifiedBy ) const outerTx = control.txFactory.createTxCollectionCUD( attachedToClass, @@ -382,7 +416,7 @@ async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): collection, innerTx, undefined, - primarySocialString + ctx.modifiedBy ) res.push(outerTx) } @@ -396,6 +430,7 @@ async function onRemoveEvent (ctx: TxRemoveDoc, control: TriggerControl): if (ctx.modifiedBy !== core.account.System) { void sendEventToService(removed, 'delete', control) } + void putEventToQueue(control, 'delete', removed, ctx.modifiedBy) if (removed.access !== 'owner') return [] const current = await control.findAll(control.ctx, calendar.class.Event, { eventId: removed.eventId }) for (const cur of current) { diff --git a/server/core/src/queue/types.ts b/server/core/src/queue/types.ts index 42fc6391e9..c51d644314 100644 --- a/server/core/src/queue/types.ts +++ b/server/core/src/queue/types.ts @@ -13,7 +13,10 @@ export enum QueueTopic { // A topic about user activity. Users = 'users', - TelegramBot = 'telegramBot' + TelegramBot = 'telegramBot', + + // A topic about calendar events. + CalendarEventCUD = 'calendarEventCUD' } export interface ConsumerHandle { diff --git a/services/calendar/pod-calendar-mailer/.eslintrc.js b/services/calendar/pod-calendar-mailer/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/services/calendar/pod-calendar-mailer/.npmignore b/services/calendar/pod-calendar-mailer/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/services/calendar/pod-calendar-mailer/Dockerfile b/services/calendar/pod-calendar-mailer/Dockerfile new file mode 100644 index 0000000000..67a8238765 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/Dockerfile @@ -0,0 +1,8 @@ + +FROM hardcoreeng/base:v20250310 +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ + +EXPOSE 8095 +CMD [ "node", "bundle.js" ] diff --git a/services/calendar/pod-calendar-mailer/config/rig.json b/services/calendar/pod-calendar-mailer/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/services/calendar/pod-calendar-mailer/jest.config.js b/services/calendar/pod-calendar-mailer/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/services/calendar/pod-calendar-mailer/package.json b/services/calendar/pod-calendar-mailer/package.json new file mode 100644 index 0000000000..33833b7d24 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/package.json @@ -0,0 +1,69 @@ +{ + "name": "@hcengineering/pod-calendar-mailer", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "_phase:bundle": "rushx bundle", + "_phase:docker-build": "rushx docker:build", + "_phase:docker-staging": "rushx docker:staging", + "bundle": "node ../../../common/scripts/esbuild.js --external=ws", + "docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/calendar-mailer", + "docker:tbuild": "docker build -t hardcoreeng/calendar-mailer . --platform=linux/amd64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/calendar-mailer", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/calendar-mailer staging", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/calendar-mailer", + "run-local": "cross-env ts-node src/index.ts", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@tsconfig/node16": "^1.0.4", + "@types/node": "~20.11.16", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/parser": "^6.11.0", + "cross-env": "~7.0.3", + "esbuild": "^0.24.2", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.8.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/account-client": "^0.6.0", + "@hcengineering/analytics": "^0.6.0", + "@hcengineering/analytics-service": "^0.6.0", + "@hcengineering/api-client": "^0.6.0", + "@hcengineering/calendar": "^0.6.24", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/kafka": "^0.6.0", + "@hcengineering/love": "^0.6.0", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", + "dotenv": "~16.0.0" + } +} diff --git a/services/calendar/pod-calendar-mailer/src/__tests__/handlers.test.ts b/services/calendar/pod-calendar-mailer/src/__tests__/handlers.test.ts new file mode 100644 index 0000000000..46e58634c0 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/__tests__/handlers.test.ts @@ -0,0 +1,223 @@ +// +// Copyright © 2025 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 { Event } from '@hcengineering/calendar' +import { PersonId, Ref, WorkspaceUuid } from '@hcengineering/core' +import { Meeting, Room } from '@hcengineering/love' +import { MeetingNotificationType } from '../notification' +import { eventCreated, eventUpdated, eventDeleted, eventMixin } from '../handlers' +import * as notification from '../notification' + +const ws = 'workspace-id' as WorkspaceUuid +const meetingHost = 'meeting-host' as PersonId +const meetingGuest = 'meeting-guest' as PersonId +const room = 'room-id' as Ref + +function eventFor (user: PersonId, props?: Partial | Partial): Event { + return { + _id: 'test-event', + user, + date: Date.now() + 3600000, + ...props + } as any as Event +} + +function pastDate (): number { + return Date.now() - 3600000 +} + +jest.mock('../notification', () => { + const actual = jest.requireActual('../notification') + return { + ...actual, + ...actual.default, + createNotification: jest.fn() + } +}) + +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils') + return { + ...actual, + ...actual.default, + isMeeting: jest.fn((ws: WorkspaceUuid, event: Event): Promise => { + return Promise.resolve((event as any as Meeting).room !== undefined) + }) + } +}) + +describe('queue message handlers', () => { + const createNotificationSpy = jest.spyOn(notification, 'createNotification') + + beforeEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + createNotificationSpy.mockRestore() + }) + + describe('eventCreated', () => { + test('there should not be notification when host creates event for himself', async () => { + const event = eventFor(meetingHost) + + await eventCreated(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should not be notification when host creates event for guest', async () => { + const event = eventFor(meetingGuest) + + await eventCreated(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should be notification when host creates meeting for guest', async () => { + // This happens when the host adds a new participant to the existing meeting + // A new event which is already a meeting is created for the new participant + const event = eventFor(meetingGuest, { room }) + + await eventCreated(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).toHaveBeenCalledWith(ws, MeetingNotificationType.Scheduled, event, meetingHost) + }) + + test('there should not be notification when host creates meeting for guest in the past', async () => { + // This happens when the host adds a new participant to the existing meeting + // A new event which is already a meeting is created for the new participant + const event = eventFor(meetingGuest, { room, date: pastDate() }) + + await eventCreated(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + }) + + describe('eventUpdated', () => { + test('there should be notification when host updates event for himself', async () => { + const event = eventFor(meetingHost) + + await eventUpdated(ws, { event, modifiedBy: meetingHost, changes: { date: Date.now() } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should not be notification when host updates event for guest', async () => { + const event = eventFor(meetingGuest) + + await eventUpdated(ws, { event, modifiedBy: meetingHost, changes: { date: Date.now() } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should be notification when host updates meeting for guest', async () => { + const event = eventFor(meetingGuest, { room }) + + await eventUpdated(ws, { event, modifiedBy: meetingHost, changes: { date: Date.now() } }) + + expect(createNotificationSpy).toHaveBeenCalledWith(ws, MeetingNotificationType.Rescheduled, event, meetingHost) + }) + + test('there should be notification when host updates meeting for guest in the past', async () => { + const event = eventFor(meetingGuest, { room, date: pastDate() }) + + await eventUpdated(ws, { event, modifiedBy: meetingHost, changes: { date: Date.now() } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should not be notification when host updates meeting for himself', async () => { + const event = eventFor(meetingHost, { room }) + + await eventUpdated(ws, { event, modifiedBy: meetingHost, changes: { date: Date.now() } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + }) + + describe('eventDeleted', () => { + test('there should not be notification when host deletes event for himself', async () => { + const event = eventFor(meetingHost) + + await eventDeleted(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should not be notification when host deletes event for guest', async () => { + const event = eventFor(meetingGuest) + + await eventDeleted(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should be notification when host deletes meeting for guest', async () => { + const event = eventFor(meetingGuest, { room }) + + await eventDeleted(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).toHaveBeenCalledWith(ws, MeetingNotificationType.Canceled, event, meetingHost) + }) + + test('there should not be notification when host deletes meeting for guest in the past', async () => { + const event = eventFor(meetingGuest, { room, date: pastDate() }) + + await eventDeleted(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should not be notification when host deletes meeting for himself', async () => { + const event = eventFor(meetingHost, { room }) + + await eventDeleted(ws, { event, modifiedBy: meetingHost }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + }) + + describe('eventMixin', () => { + test('there should not be notification when event not created before mixin', async () => { + const event = eventFor(meetingGuest) + + await eventMixin(ws, { event, modifiedBy: meetingHost, changes: { room } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + + test('there should be notification when host create meeting for guest', async () => { + const event0 = eventFor(meetingGuest) + await eventCreated(ws, { event: event0, modifiedBy: meetingHost }) + + const event = eventFor(meetingGuest, { room }) + await eventMixin(ws, { event, modifiedBy: meetingHost, changes: { room } }) + + expect(createNotificationSpy).toHaveBeenCalledWith(ws, MeetingNotificationType.Scheduled, event, meetingHost) + }) + + test('there should not be notification when host created meeting for himself', async () => { + const event0 = eventFor(meetingHost) + await eventCreated(ws, { event: event0, modifiedBy: meetingHost }) + + const event = eventFor(meetingHost, { room }) + await eventMixin(ws, { event, modifiedBy: meetingHost, changes: { room } }) + + expect(createNotificationSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/services/calendar/pod-calendar-mailer/src/config.ts b/services/calendar/pod-calendar-mailer/src/config.ts new file mode 100644 index 0000000000..14e75994f7 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/config.ts @@ -0,0 +1,35 @@ +// +// Copyright © 2025 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 { config as dotenvConfig } from 'dotenv' + +dotenvConfig() + +interface Config { + secret: string + sentryDSN: string + accountsUrl: string + smtpUrl: string + queueRegion: string +} + +const config: Config = { + secret: process.env.SECRET ?? 'secret', + sentryDSN: process.env.SENTRY_DSN ?? '', + accountsUrl: process.env.ACCOUNTS_URL ?? 'http://localhost:3000', + smtpUrl: process.env.SMTP_URL ?? 'http://localhost:8097', + queueRegion: process.env.QUEUE_REGION ?? 'localhost' +} + +export default config diff --git a/services/calendar/pod-calendar-mailer/src/handlers.ts b/services/calendar/pod-calendar-mailer/src/handlers.ts new file mode 100644 index 0000000000..ee197cdbf7 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/handlers.ts @@ -0,0 +1,116 @@ +// +// Copyright © 2025 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 { Event } from '@hcengineering/calendar' +import { Data, WorkspaceUuid } from '@hcengineering/core' +import { createNotification, MeetingNotificationType } from './notification' +import { type EventCUDMessage } from './types' +import { isMeeting } from './utils' + +const recentlyCreatedEvents = new Map() + +function addRecentEvent (eventId: string): void { + recentlyCreatedEvents.set(eventId, Date.now()) + + const now = Date.now() + const deleteIds: string[] = [] + for (const [eventId, timestamp] of recentlyCreatedEvents.entries()) { + if (now - timestamp > 60_000) { + deleteIds.push(eventId) + } + } + for (const eventId of deleteIds) { + recentlyCreatedEvents.delete(eventId) + } +} + +export async function eventCreated ( + workspaceUuid: WorkspaceUuid, + message: Omit +): Promise { + const { event, modifiedBy } = message + + // TODO: move emailing logic from huly-schedule here + + if (event.date <= Date.now()) return + + if (modifiedBy !== event.user) { + if (await isMeeting(workspaceUuid, event)) { + // This happens when the host adds a new participant to the existing meeting + // A new event which is already a meeting is created for the new participant + await createNotification(workspaceUuid, MeetingNotificationType.Scheduled, event, modifiedBy) + } else { + // Don't create notifications for reguar events, only for meetings + // But the event will be marked as a meeting in a separate call + // immediately after creation, in the "mixin" message + addRecentEvent(event._id) + } + } +} + +export async function eventUpdated ( + workspaceUuid: WorkspaceUuid, + message: Omit +): Promise { + const { event, modifiedBy } = message + + // TODO: if the event was created via huly-schedule, we need to send an email + + if (event.date <= Date.now()) return + + if (modifiedBy !== event.user) { + if (await isMeeting(workspaceUuid, event)) { + const changes = message.changes as Partial> + if (changes.date !== undefined) { + await createNotification(workspaceUuid, MeetingNotificationType.Rescheduled, event, modifiedBy) + } + } + } +} + +export async function eventDeleted ( + workspaceUuid: WorkspaceUuid, + message: Omit +): Promise { + const { event, modifiedBy } = message + + // TODO: if the event was created via huly-schedule, we need to send an email + + if (event.date <= Date.now()) return + + if (modifiedBy !== event.user) { + if (await isMeeting(workspaceUuid, event)) { + await createNotification(workspaceUuid, MeetingNotificationType.Canceled, event, modifiedBy) + } + } +} + +export async function eventMixin ( + workspaceUuid: WorkspaceUuid, + message: Omit +): Promise { + const { event, modifiedBy } = message + + // TODO: move emailing logic from huly-schedule here + + if (modifiedBy !== event.user) { + if (recentlyCreatedEvents.has(event._id)) { + recentlyCreatedEvents.delete(event._id) + + if (await isMeeting(workspaceUuid, event)) { + await createNotification(workspaceUuid, MeetingNotificationType.Scheduled, event, modifiedBy) + } + } + } +} diff --git a/services/calendar/pod-calendar-mailer/src/index.ts b/services/calendar/pod-calendar-mailer/src/index.ts new file mode 100644 index 0000000000..cc32ca52ac --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/index.ts @@ -0,0 +1,99 @@ +// +// Copyright © 2025 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 { join } from 'path' +import { Analytics } from '@hcengineering/analytics' +import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' +import { MeasureMetricsContext, newMetrics, WorkspaceUuid } from '@hcengineering/core' +import { setMetadata } from '@hcengineering/platform' +import { initStatisticsContext, QueueTopic } from '@hcengineering/server-core' +import serverToken from '@hcengineering/server-token' +import { getPlatformQueue } from '@hcengineering/kafka' +import config from './config' +import { EventCUDMessage } from './types' +import { eventCreated, eventUpdated, eventDeleted, eventMixin } from './handlers' + +async function main (): Promise { + const ctx = initStatisticsContext('calendar-mailer', { + factory: () => + new MeasureMetricsContext( + 'calendar-mailer', + {}, + {}, + newMetrics(), + new SplitLogger('calendar-mailer', { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) + }) + + configureAnalytics(config.sentryDSN, config) + Analytics.setTag('application', 'telegram-bot-service') + setMetadata(serverToken.metadata.Secret, config.secret) + + const queue = getPlatformQueue('calendarMailerService', config.queueRegion) + + const consumer = queue.createConsumer( + ctx, + QueueTopic.CalendarEventCUD, + queue.getClientId(), + async (messages) => { + for (const message of messages) { + const ws = message.id as WorkspaceUuid + const records = message.value + for (const record of records) { + try { + switch (record.action) { + case 'create': + await eventCreated(ws, record) + break + case 'update': + await eventUpdated(ws, record) + break + case 'delete': + await eventDeleted(ws, record) + break + case 'mixin': + await eventMixin(ws, record) + break + } + } catch (error) { + ctx.error('Error processing message', { error, ws, record }) + } + } + } + } + ) + + const shutdown = (): void => { + void Promise.all([consumer.close()]).then(() => { + process.exit() + }) + } + + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) + process.on('uncaughtException', (error: any) => { + ctx.error('Uncaught exception', { error }) + }) + process.on('unhandledRejection', (error: any) => { + ctx.error('Unhandled rejection', { error }) + }) +} + +void main().catch((err) => { + console.error(err) +}) diff --git a/services/calendar/pod-calendar-mailer/src/notification.ts b/services/calendar/pod-calendar-mailer/src/notification.ts new file mode 100644 index 0000000000..acd5914c73 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/notification.ts @@ -0,0 +1,105 @@ +// +// Copyright © 2025 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 calendar, { Event } from '@hcengineering/calendar' +import contact from '@hcengineering/contact' +import { AccountUuid, Doc, PersonId, Ref, Space, WorkspaceUuid } from '@hcengineering/core' +import notification from '@hcengineering/notification' +import { IntlString } from '@hcengineering/platform' +import { getClient } from './utils' + +export enum MeetingNotificationType { + Scheduled = 'meeting-scheduled', + Rescheduled = 'meeting-rescheduled', + Canceled = 'meeting-canceled' +} + +const notificationMessages: Record = { + [MeetingNotificationType.Scheduled]: calendar.string.MeetingScheduledNotification, + [MeetingNotificationType.Rescheduled]: calendar.string.MeetingRescheduledNotification, + [MeetingNotificationType.Canceled]: calendar.string.MeetingCanceledNotification +} + +export async function createNotification ( + workspaceUuid: WorkspaceUuid, + type: MeetingNotificationType, + forEvent: Event, + modifiedBy: PersonId +): Promise { + const { client, accountClient } = await getClient(workspaceUuid) + const personUuid = await accountClient.findPersonBySocialId(forEvent.user, true) + if (personUuid === undefined) { + throw new Error(`Global person not found for social-id ${forEvent.user}`) + } + const person = await client.findOne(contact.class.Person, { personUuid }, { projection: { _id: 1 } }) + if (person === undefined) { + throw new Error(`Local person not found for person-uuid ${personUuid}`) + } + const space = await client.findOne(contact.class.PersonSpace, { person: person._id }, { projection: { _id: 1 } }) + if (space === undefined) { + throw new Error(`Person space not found for person ${person._id}`) + } + + const user = personUuid as AccountUuid + let objectId: Ref> = forEvent._id + let objectClass = forEvent._class + let objectSpace = forEvent.space + + if (type === MeetingNotificationType.Canceled) { + const calendr = await client.findOne( + calendar.class.Calendar, + { _id: forEvent.calendar }, + { projection: { _id: 1 } } + ) + if (calendr === undefined) { + throw new Error(`Calendar not found for event ${forEvent._id}`) + } + objectId = calendr._id + objectClass = calendar.class.Calendar + objectSpace = calendr.space + } + + const docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { objectId, user }) + let docNotifyContextId = docNotifyContext?._id + if (docNotifyContextId === undefined) { + docNotifyContextId = await client.createDoc(notification.class.DocNotifyContext, space._id, { + objectId, + objectClass, + objectSpace, + user, + isPinned: false, + hidden: false + }) + } + + if (notificationMessages[type] === undefined) { + throw new Error('Invalid notification type') + } + + // Here we need another client with that account who created the event + // to make the notification in the inbox displaying the author name + const { client: txClient } = await getClient(workspaceUuid, modifiedBy) + + await txClient.createDoc(notification.class.CommonInboxNotification, space._id, { + user, + objectId, + objectClass, + icon: calendar.icon.Calendar, + message: notificationMessages[type], + props: { title: forEvent.title }, + isViewed: false, + archived: false, + docNotifyContext: docNotifyContextId + }) +} diff --git a/services/calendar/pod-calendar-mailer/src/types.ts b/services/calendar/pod-calendar-mailer/src/types.ts new file mode 100644 index 0000000000..164ed8492e --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/types.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2025 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 { Event } from '@hcengineering/calendar' +import { Data, PersonId } from '@hcengineering/core' +import { Meeting } from '@hcengineering/love' + +export interface EventCUDMessage { + action: 'create' | 'update' | 'delete' | 'mixin' + event: Event + modifiedBy: PersonId + changes?: Partial> | Partial> +} diff --git a/services/calendar/pod-calendar-mailer/src/utils.ts b/services/calendar/pod-calendar-mailer/src/utils.ts new file mode 100644 index 0000000000..bda5f3ac28 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/src/utils.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2025 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 { getClient as getAccountClient, AccountClient } from '@hcengineering/account-client' +import { createRestTxOperations } from '@hcengineering/api-client' +import { Event } from '@hcengineering/calendar' +import { Hierarchy, PersonId, systemAccountUuid, TxOperations, WorkspaceUuid } from '@hcengineering/core' +import love from '@hcengineering/love' +import { generateToken } from '@hcengineering/server-token' +import config from './config' + +export async function getClient ( + workspaceUuid: WorkspaceUuid, + socialId?: PersonId +): Promise<{ client: TxOperations, accountClient: AccountClient }> { + const token = generateToken(systemAccountUuid, workspaceUuid) + let accountClient = getAccountClient(config.accountsUrl, token) + + if (socialId !== undefined) { + const personUuid = await accountClient.findPersonBySocialId(socialId, true) + if (personUuid === undefined) { + throw new Error('Global person not found') + } + const token = generateToken(personUuid, workspaceUuid) + accountClient = getAccountClient(config.accountsUrl, token) + } + + const wsInfo = await accountClient.getLoginInfoByToken() + if (!('endpoint' in wsInfo)) { + throw new Error('Invalid login info') + } + const transactorUrl = wsInfo.endpoint.replace('ws://', 'http://').replace('wss://', 'https://') + const client = await createRestTxOperations(transactorUrl, wsInfo.workspace, wsInfo.token) + return { client, accountClient } +} + +const hierarchies = new Map() + +export async function isMeeting (workspaceUuid: WorkspaceUuid, event: Event): Promise { + let hierarchy = hierarchies.get(workspaceUuid) + if (hierarchy === undefined) { + const { client } = await getClient(workspaceUuid) + hierarchy = client.getHierarchy() + hierarchies.set(workspaceUuid, hierarchy) + } + return hierarchy.hasMixin(event, love.mixin.Meeting) +} diff --git a/services/calendar/pod-calendar-mailer/tsconfig.json b/services/calendar/pod-calendar-mailer/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/services/calendar/pod-calendar-mailer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file