mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-28 04:25:03 +02:00
Calendar event queue handler (#8630)
Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
This commit is contained in:
Generated
+38
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,9 @@
|
||||
"CalDavAccessServer": "Сервер CalDAV",
|
||||
"CalDavAccessAccount": "Учетная запись Huly",
|
||||
"CalDavAccessPassword": "Пароль",
|
||||
"CalDavAccessPasswordWarning": "Вы не сможете увидеть этот пароль снова. Пожалуйста, сохраните его в безопасном месте."
|
||||
"CalDavAccessPasswordWarning": "Вы не сможете увидеть этот пароль снова. Пожалуйста, сохраните его в безопасном месте.",
|
||||
"MeetingScheduledNotification": "Встреча запланирована",
|
||||
"MeetingRescheduledNotification": "Время встречи изменено",
|
||||
"MeetingCanceledNotification": "Встреча отменена: {title}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@
|
||||
"CalDavAccessServer": "CalDAV 服务器",
|
||||
"CalDavAccessAccount": "Huly 帐户",
|
||||
"CalDavAccessPassword": "密码",
|
||||
"CalDavAccessPasswordWarning": "您将无法再次看到此密码。请将其保存在安全的地方。"
|
||||
"CalDavAccessPasswordWarning": "您将无法再次看到此密码。请将其保存在安全的地方。",
|
||||
"MeetingScheduledNotification": "会议已安排",
|
||||
"MeetingRescheduledNotification": "会议时间已更改",
|
||||
"MeetingCanceledNotification": "会议已取消: {title}"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
}
|
||||
&.description {
|
||||
padding: 0 1.25rem;
|
||||
min-height: 2.625rem;
|
||||
}
|
||||
}
|
||||
.top-icon {
|
||||
|
||||
@@ -44,23 +44,30 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-row-center gap-1" class:pb-0-5={reminders.length}>
|
||||
<Icon icon={calendar.icon.Notifications} size="small" />
|
||||
<Button
|
||||
label={reminders.length > 0 ? calendar.string.AddReminder : calendar.string.Reminders}
|
||||
{disabled}
|
||||
justify={'left'}
|
||||
flex="1"
|
||||
padding="0.5rem"
|
||||
kind={'ghost'}
|
||||
{focusIndex}
|
||||
on:click={(e) => {
|
||||
addReminder(e)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if !disabled}
|
||||
<div class="flex-row-center gap-1" class:pb-0-5={reminders.length}>
|
||||
<Icon icon={calendar.icon.Notifications} size="small" />
|
||||
<Button
|
||||
label={reminders.length > 0 ? calendar.string.AddReminder : calendar.string.Reminders}
|
||||
{disabled}
|
||||
justify={'left'}
|
||||
flex="1"
|
||||
padding="0.5rem"
|
||||
kind={'ghost'}
|
||||
{focusIndex}
|
||||
on:click={(e) => {
|
||||
addReminder(e)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reminders.length}
|
||||
<Scroller padding={'.125rem 0 0 1.25rem'} shrink>
|
||||
{#if disabled}
|
||||
<div style="position:absolute; left:0; top:0.625rem">
|
||||
<Icon icon={calendar.icon.Participants} size="small" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if reminders.length > maxReminders && !shown}
|
||||
{#each reminders.slice(0, maxReminders - 2) as reminder, i}
|
||||
<EventReminderItem
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
|
||||
function change (val: Visibility): void {
|
||||
if (value !== val) {
|
||||
console.log('Changed', val)
|
||||
dispatch('change', val)
|
||||
value = val
|
||||
}
|
||||
@@ -70,7 +69,6 @@
|
||||
opened = true
|
||||
closeTooltip()
|
||||
showPopup(ModernPopup, { items, selected: selected?.id }, eventToHTMLElement(ev), (selectedId) => {
|
||||
console.log('Selected', selectedId)
|
||||
if (selectedId !== undefined) {
|
||||
change(selectedId)
|
||||
}
|
||||
|
||||
@@ -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<Resources> => ({
|
||||
}
|
||||
},
|
||||
function: {
|
||||
ConfigureCalDavAccess: configureCalDavAccess
|
||||
ConfigureCalDavAccess: configureCalDavAccess,
|
||||
EventTitleProvider: eventTitleProvider
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>, doc?: Event): Promise<string> {
|
||||
const object = doc ?? (await client.findOne(calendar.class.Event, { _id: ref as Ref<Event> }))
|
||||
if (object === undefined) {
|
||||
return ''
|
||||
}
|
||||
return object.title
|
||||
}
|
||||
|
||||
@@ -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<Event>
|
||||
},
|
||||
function: {
|
||||
ConfigureCalDavAccess: '' as Resource<() => Promise<void>>
|
||||
ConfigureCalDavAccess: '' as Resource<() => Promise<void>>,
|
||||
EventTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
kind={'ghost'}
|
||||
padding={'0 .5rem'}
|
||||
justify={'left'}
|
||||
flex="1"
|
||||
on:click={() => {
|
||||
isMeeting = !isMeeting
|
||||
changeIsMeeting(isMeeting)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Ref<Person> | 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<Tx[]> {
|
||||
async function onEventMixin (ctx: TxMixin<Event, Event>, control: TriggerControl): Promise<Tx[]> {
|
||||
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<Event>, 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<string, any>
|
||||
}
|
||||
|
||||
async function putEventToQueue (
|
||||
control: TriggerControl,
|
||||
action: EventCUDType,
|
||||
event: Event,
|
||||
modifiedBy: PersonId,
|
||||
changes?: Record<string, any>
|
||||
): Promise<void> {
|
||||
if (control.queue === undefined) {
|
||||
return
|
||||
}
|
||||
const producer = control.queue.createProducer<EventCUDMessage>(
|
||||
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<Event>, control: TriggerControl): Promise<Tx[]> {
|
||||
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<Event>, control: TriggerControl):
|
||||
const socialIds = await getSocialIds(control, part as Ref<Person>)
|
||||
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<Event>, 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<Event>, control: TriggerControl):
|
||||
collection,
|
||||
innerTx,
|
||||
undefined,
|
||||
primarySocialString
|
||||
ctx.modifiedBy
|
||||
)
|
||||
res.push(outerTx)
|
||||
}
|
||||
@@ -396,6 +430,7 @@ async function onRemoveEvent (ctx: TxRemoveDoc<Event>, 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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
FROM hardcoreeng/base:v20250310
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY bundle/bundle.js ./
|
||||
|
||||
EXPOSE 8095
|
||||
CMD [ "node", "bundle.js" ]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Room>
|
||||
|
||||
function eventFor (user: PersonId, props?: Partial<Event> | Partial<Meeting>): 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<boolean> => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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<string, number>()
|
||||
|
||||
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<EventCUDMessage, 'action'>
|
||||
): Promise<void> {
|
||||
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<EventCUDMessage, 'action'>
|
||||
): Promise<void> {
|
||||
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<Data<Event>>
|
||||
if (changes.date !== undefined) {
|
||||
await createNotification(workspaceUuid, MeetingNotificationType.Rescheduled, event, modifiedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function eventDeleted (
|
||||
workspaceUuid: WorkspaceUuid,
|
||||
message: Omit<EventCUDMessage, 'action'>
|
||||
): Promise<void> {
|
||||
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<EventCUDMessage, 'action'>
|
||||
): Promise<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<EventCUDMessage>(
|
||||
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)
|
||||
})
|
||||
@@ -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, IntlString> = {
|
||||
[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<void> {
|
||||
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<Doc<Space>> = 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
|
||||
})
|
||||
}
|
||||
@@ -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<Data<Event>> | Partial<Data<Meeting>>
|
||||
}
|
||||
@@ -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<WorkspaceUuid, Hierarchy>()
|
||||
|
||||
export async function isMeeting (workspaceUuid: WorkspaceUuid, event: Event): Promise<boolean> {
|
||||
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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user