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
]