Files
huly-platform/services/notification/pod-events-processor/src/worker.ts
T
Artyom SavchenkoandGitHub dfe7d3d17c feat: Add ability to schedule notifications (#10789)
* feat: Add ability to schedule notifications

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add docker file

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Rename pod

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Add debug logging

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Reminder fixes

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix reminders

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Support reminders for all events

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Support for project todo

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix mismatched dependency

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Use base event class

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
2026-07-06 11:30:21 +05:00

234 lines
7.8 KiB
TypeScript

//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import calendar from '@hcengineering/calendar'
import contact, { type Person, type PersonSpace } from '@hcengineering/contact'
import core, {
type AccountUuid,
type Class,
type Doc,
type Hierarchy,
type MeasureContext,
type PersonId,
type Ref,
type Space,
type WorkspaceUuid
} from '@hcengineering/core'
import type { ConsumerControl } from '@hcengineering/server-core'
import notification, { type CommonInboxNotification, type DocNotifyContext } from '@hcengineering/notification'
import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/text-core'
import time, { type ToDo } from '@hcengineering/time'
import { getClient, type ClientBundle } from './client'
import type { ScheduledNotificationMessage } from './types'
interface MinimalEvent extends Doc {
attachedTo?: Ref<Doc>
attachedToClass?: Ref<Class<Doc>>
user?: PersonId
title?: string
}
export function buildReminderNotificationId (timerId: string): Ref<CommonInboxNotification> {
return `eventReminderInbox:${timerId}` as Ref<CommonInboxNotification>
}
interface ReminderTarget {
objectId: Ref<any>
objectClass: Ref<Class<any>>
objectSpace: Ref<Space>
titleText: string
receiverAccount: AccountUuid
receiverSpace: Ref<PersonSpace>
}
export async function handleScheduledNotification (
ctx: MeasureContext,
workspaceUuid: WorkspaceUuid,
msg: ScheduledNotificationMessage,
control: ConsumerControl
): Promise<void> {
if (msg.kind !== 'eventReminder') return
await control.heartbeat()
const bundle = await getClient(workspaceUuid)
const { client } = bundle
await control.heartbeat()
const event = (await client.findOne(msg.eventClass, { _id: msg.eventId })) as MinimalEvent | undefined
if (event === undefined) return
await control.heartbeat()
const hierarchy = client.getHierarchy()
const target = await resolveReminderTarget(bundle, event, hierarchy)
if (target === undefined) return
await control.heartbeat()
// Idempotency: short-circuit if a notification for this exact timer was already created.
const notificationId = buildReminderNotificationId(msg.id)
const existing = await client.findOne(
notification.class.CommonInboxNotification,
{ _id: notificationId },
{ projection: { _id: 1 } }
)
if (existing !== undefined) return
// Ensure doc notify context exists.
let docNotifyContext: Pick<DocNotifyContext, '_id'> | undefined = await client.findOne(
notification.class.DocNotifyContext,
{ objectId: target.objectId, user: target.receiverAccount },
{ projection: { _id: 1 } }
)
if (docNotifyContext === undefined) {
try {
const id = await client.createDoc(
notification.class.DocNotifyContext,
target.receiverSpace,
{
objectId: target.objectId,
objectClass: target.objectClass,
objectSpace: target.objectSpace,
user: target.receiverAccount,
isPinned: false,
hidden: false
},
undefined,
undefined,
// System tokens may not have a populated `socialIds[0]`; if we let the rest tx client default
// to that, `Tx.modifiedBy` becomes `undefined` and the transactor rejects the request with
// HTTP 400 "Bad Request" out of `NormalizeTxMiddleware.parseBaseTx`. Always pin to a known PersonId.
core.account.System
)
docNotifyContext = { _id: id }
} catch (err) {
ctx.error('Failed to create DocNotifyContext for event reminder', {
err,
timerId: msg.id,
eventId: msg.eventId,
eventClass: msg.eventClass,
spaceId: target.receiverSpace,
user: target.receiverAccount
})
throw err
}
}
await control.heartbeat()
try {
await client.createDoc(
notification.class.CommonInboxNotification,
target.receiverSpace,
{
user: target.receiverAccount,
objectId: target.objectId,
objectClass: target.objectClass,
headerIcon: calendar.icon.Reminder,
header: calendar.string.Reminder,
message: calendar.string.Reminder,
messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(target.titleText)))),
types: [calendar.ids.ReminderNotification],
isViewed: false,
archived: false,
docNotifyContext: docNotifyContext._id
},
notificationId,
undefined,
core.account.System
)
} catch (err) {
ctx.error('Failed to create CommonInboxNotification for event reminder', {
err,
timerId: msg.id,
eventId: msg.eventId,
eventClass: msg.eventClass,
notificationId,
spaceId: target.receiverSpace,
user: target.receiverAccount
})
throw err
}
ctx.info('Scheduled notification created', {
kind: msg.kind,
id: msg.id,
eventId: msg.eventId,
eventClass: msg.eventClass,
notificationId,
user: target.receiverAccount,
spaceId: target.receiverSpace
})
}
// Resolves where a reminder fires:
// - For Events whose `attachedToClass` is `time:class:ToDo` (or a subclass like `ProjectToDo`),
// the notification points at the parent ToDo and is suppressed when the ToDo is already done.
// - For any other Event the notification points at the Event itself.
async function resolveReminderTarget (
bundle: ClientBundle,
event: MinimalEvent,
hierarchy: Hierarchy
): Promise<ReminderTarget | undefined> {
const { client, accountClient } = bundle
const isToDoBacked = event.attachedToClass != null && hierarchy.isDerived(event.attachedToClass, time.class.ToDo)
if (isToDoBacked && event.attachedTo != null && event.attachedToClass != null) {
const todo = (await client.findOne(event.attachedToClass as Ref<Class<ToDo>>, {
_id: event.attachedTo as Ref<ToDo>
})) as ToDo | undefined
if (todo === undefined) return undefined
if (todo.doneOn != null) return undefined
const employee = await client.findOne(contact.mixin.Employee, { _id: todo.user, active: true })
if (employee?.personUuid == null) return undefined
const space = await client.findOne(contact.class.PersonSpace, { person: todo.user }, { projection: { _id: 1 } })
if (space === undefined) return undefined
return {
objectId: todo._id,
objectClass: todo._class,
objectSpace: todo.space,
titleText: todo.title ?? '',
receiverAccount: employee.personUuid,
receiverSpace: space._id
}
}
// Plain calendar event: receiver is identified by `event.user` (a PersonId / social id).
const receiverSocialId = event.user
if (receiverSocialId == null) return undefined
const personUuid = await accountClient.findPersonBySocialId(receiverSocialId, true)
if (personUuid == null) return undefined
const person = await client.findOne(contact.class.Person, { personUuid }, { projection: { _id: 1 } })
if (person === undefined) return undefined
const space = await client.findOne(
contact.class.PersonSpace,
{ person: person._id as Ref<Person> },
{ projection: { _id: 1 } }
)
if (space === undefined) return undefined
return {
objectId: event._id,
objectClass: event._class,
objectSpace: event.space,
titleText: event.title ?? '',
receiverAccount: personUuid as AccountUuid,
receiverSpace: space._id
}
}