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>
This commit is contained in:
Artyom Savchenko
2026-07-06 11:30:21 +05:00
committed by GitHub
parent 184b4ec08f
commit dfe7d3d17c
40 changed files with 2157 additions and 57 deletions
@@ -44,6 +44,128 @@ import { QueueTopic, TriggerControl } from '@hcengineering/server-core'
import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources'
import { generateToken } from '@hcengineering/server-token'
const scheduledNotificationTopic = 'scheduledNotification'
interface ScheduledNotificationMessage {
kind: 'eventReminder'
id: string
eventId: Ref<Event>
eventClass: Ref<Class<Event>>
shiftMs: number
targetDate: number
}
type TimeMachineMessage =
| {
type: 'schedule'
id: string
targetDate: number
topic: string
data: ScheduledNotificationMessage
}
| {
type: 'cancel'
id: string
}
type TimeMachineScheduleMessage = Extract<TimeMachineMessage, { type: 'schedule' }>
function eventReminderPrefix (eventId: Ref<Event>): string {
return `eventReminder_${eventId}_`
}
function eventReminderTimerId (eventId: Ref<Event>, shiftMs: number): string {
// Stable so we can cancel by `${prefix}%`.
return `${eventReminderPrefix(eventId)}${shiftMs}`
}
async function cancelEventReminders (control: TriggerControl, eventId: Ref<Event>): Promise<void> {
try {
const queue = control.queue
if (queue === undefined) return
const producer = queue.getProducer<TimeMachineMessage>(control.ctx, QueueTopic.TimeMachine)
const cancelId = `${eventReminderPrefix(eventId)}%`
await producer.send(control.ctx, control.workspace.uuid, [{ type: 'cancel', id: cancelId }])
control.ctx.info('Queued event reminder cancel', {
queueTopic: QueueTopic.TimeMachine,
eventId,
timerIdPattern: cancelId
})
} catch (err) {
control.ctx.error('Failed to cancel Event reminders', { err, eventId })
}
}
async function scheduleEventReminders (control: TriggerControl, eventId: Ref<Event>): Promise<void> {
try {
const queue = control.queue
if (queue === undefined) return
const event = (await control.findAll(control.ctx, calendar.class.Event, { _id: eventId }, { limit: 1 }))[0]
if (event === undefined) return
// Reset existing timers for this Event on any relevant change.
await cancelEventReminders(control, eventId)
const reminders = event.reminders ?? []
if (reminders.length === 0) return
const now = Date.now()
const msgs: TimeMachineScheduleMessage[] = []
for (const shiftMs of reminders) {
if (typeof shiftMs !== 'number' || Number.isNaN(shiftMs)) continue
// `shiftMs` is the positive offset before the event (in ms), matching the convention used by
// ReminderPopup and pod-calendar Google export.
const targetDate = event.date - shiftMs
if (targetDate <= now) continue
const id = eventReminderTimerId(eventId, shiftMs)
const data: ScheduledNotificationMessage = {
kind: 'eventReminder',
id,
eventId,
eventClass: event._class,
shiftMs,
targetDate
}
msgs.push({
type: 'schedule',
id,
targetDate,
topic: scheduledNotificationTopic,
data
})
}
if (msgs.length === 0) {
control.ctx.info('Skipped event reminder scheduling', {
queueTopic: QueueTopic.TimeMachine,
eventId,
eventClass: event._class,
reminderCount: reminders.length,
reason: 'no-future-reminders'
})
return
}
const producer = queue.getProducer<TimeMachineMessage>(control.ctx, QueueTopic.TimeMachine)
await producer.send(control.ctx, control.workspace.uuid, msgs)
control.ctx.info('Queued event reminders', {
queueTopic: QueueTopic.TimeMachine,
eventId,
eventClass: event._class,
reminderCount: reminders.length,
enqueuedCount: msgs.length,
timerIds: msgs.map((msg) => msg.id),
targetDates: msgs.map((msg) => msg.targetDate)
})
} catch (err) {
control.ctx.error('Failed to schedule Event reminders', { err, eventId })
}
}
export { scheduleEventReminders, cancelEventReminders }
/**
* @public
*/
@@ -214,6 +336,10 @@ async function onEventUpdate (ctx: TxUpdateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'update', control)
}
void putEventToQueue(control, 'update', event, ctx.modifiedBy, ops)
// Reschedule reminders if the event start time or the reminder offsets changed.
if (ops.date !== undefined || ops.reminders !== undefined) {
void scheduleEventReminders(control, ctx.objectId)
}
if (event.access !== 'owner') return []
const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId })
const res: Tx[] = []
@@ -363,6 +489,8 @@ async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'create', control)
}
void putEventToQueue(control, 'create', event, ctx.modifiedBy)
// Schedule reminders for any newly created event (including WorkSlots, since those are Events too).
void scheduleEventReminders(control, event._id)
if (event.access !== 'owner') return []
const res: Tx[] = []
const { _class, space, ...attr } = event
@@ -398,6 +526,7 @@ async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl):
async function onRemoveEvent (ctx: TxRemoveDoc<Event>, control: TriggerControl): Promise<Tx[]> {
const removed = control.removedMap.get(ctx.objectId) as Event
const res: Tx[] = []
void cancelEventReminders(control, ctx.objectId)
if (removed !== undefined) {
if (ctx.modifiedBy !== core.account.System && removed.access === 'owner') {
void sendEventToService(removed, 'delete', control)
@@ -0,0 +1,243 @@
//
// 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.
//
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import calendar, { type Event } from '@hcengineering/calendar'
import core, { generateId } from '@hcengineering/core'
import type { PlatformQueueProducer } from '@hcengineering/server-core'
import { cancelEventReminders, scheduleEventReminders } from './index'
type AnyProducer = PlatformQueueProducer<any>
function makeQueueMock (): { queue: { getProducer: jest.Mock }, send: jest.Mock } {
const send = jest.fn(async () => {})
const producer: AnyProducer = { send, close: async () => {}, getQueue: () => queue as any } as any
const queue = {
getProducer: jest.fn(() => producer)
}
return { queue, send }
}
function makeControl (overrides: Partial<any> = {}): { control: any, send: jest.Mock } {
const { queue, send } = makeQueueMock()
const control: any = {
ctx: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
contextData: { account: { uuid: generateId(), primarySocialId: core.account.System } }
},
workspace: { uuid: generateId(), url: 'ws', dataId: 'ws' },
hierarchy: {
isDerived: jest.fn(() => true),
classHierarchyMixin: jest.fn(() => undefined)
},
modelDb: { findAll: jest.fn(), findAllSync: jest.fn(), getObject: jest.fn() },
removedMap: new Map(),
userStatusMap: new Map(),
queue,
cache: new Map(),
contextCache: new Map(),
storageAdapter: {} as any,
serviceAdaptersManager: {} as any,
lowLevel: {} as any,
txFactory: { createTxUpdateDoc: jest.fn(), createTxRemoveDoc: jest.fn(), createTxCollectionCUD: jest.fn() } as any,
apply: jest.fn(async () => ({})),
domainRequest: jest.fn(async () => ({})),
queryFind: jest.fn(async () => []),
txes: [],
findAll: jest.fn(async () => [])
}
Object.assign(control, overrides)
return { control, send }
}
describe('event reminder scheduling (TimeMachine)', () => {
const eventClass = calendar.class.Event
it('schedules reminders at `event.date - shiftMs` and uses the eventReminder_ timer prefix', async () => {
const eventId = generateId() as any
// 1 hour in the future, so a 5-minute "before" reminder is in the future too.
const eventDate = Date.now() + 60 * 60_000
const shiftMs = 5 * 60_000
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: [shiftMs]
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
// Producer.send signature: (ctx, workspace, msgs).
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const cancelMsg = msgs.find((m: any) => m.type === 'cancel')
const scheduleMsg = msgs.find((m: any) => m.type === 'schedule' && m.topic === 'scheduledNotification')
expect(cancelMsg).toBeDefined()
expect(cancelMsg.id).toBe(`eventReminder_${eventId}_%`)
expect(scheduleMsg).toBeDefined()
expect(scheduleMsg.id).toBe(`eventReminder_${eventId}_${shiftMs}`)
// Reminder must fire BEFORE the event, exactly `shiftMs` earlier.
expect(scheduleMsg.targetDate).toBe(eventDate - shiftMs)
expect(scheduleMsg.data.kind).toBe('eventReminder')
expect(scheduleMsg.data.eventId).toBe(eventId)
expect(scheduleMsg.data.eventClass).toBe(eventClass)
expect(scheduleMsg.data.shiftMs).toBe(shiftMs)
expect(scheduleMsg.data.targetDate).toBe(eventDate - shiftMs)
})
it('skips reminders that resolve to the past', async () => {
const eventId = generateId() as any
// Event 1 minute in the future, 5-min reminder lands 4 minutes in the past — must be skipped.
const eventDate = Date.now() + 60_000
const shiftMs = 5 * 60_000
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: [shiftMs]
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
expect(msgs.find((m: any) => m.type === 'schedule')).toBeUndefined()
// Cancel for the prefix is still issued so any prior timers get cleared.
expect(msgs.find((m: any) => m.type === 'cancel')).toBeDefined()
})
it('does not schedule when the event has no reminders configured', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: Date.now() + 60 * 60_000,
dueDate: Date.now() + 70 * 60_000,
reminders: []
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
// Cancel always issued (resets prior state); but no schedule msg.
expect(msgs.find((m: any) => m.type === 'schedule')).toBeUndefined()
})
it('schedules ALL future reminders when more than one is configured', async () => {
const eventId = generateId() as any
const eventDate = Date.now() + 60 * 60_000
const shifts = [5 * 60_000, 15 * 60_000, 30 * 60_000]
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: shifts
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const scheduleMsgs = msgs.filter((m: any) => m.type === 'schedule')
expect(scheduleMsgs).toHaveLength(shifts.length)
for (const shiftMs of shifts) {
const m = scheduleMsgs.find((s: any) => s.id === `eventReminder_${eventId}_${shiftMs}`)
expect(m).toBeDefined()
expect(m.targetDate).toBe(eventDate - shiftMs)
}
})
it('cancelEventReminders sends a wildcard cancel for the prefix', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
await cancelEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const cancelMsg = msgs.find((m: any) => m.type === 'cancel')
expect(cancelMsg).toBeDefined()
expect(cancelMsg.id).toBe(`eventReminder_${eventId}_%`)
})
it('does nothing when control.queue is undefined', async () => {
const eventId = generateId() as any
const { control, send } = makeControl({ queue: undefined })
await scheduleEventReminders(control, eventId)
await cancelEventReminders(control, eventId)
expect(send).not.toHaveBeenCalled()
})
it('does nothing when the event is not found', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockResolvedValue([])
await scheduleEventReminders(control, eventId)
// No event to schedule for — only the cancel-on-reset behavior is skipped too because we bail
// before that. Verify nothing was sent.
expect(send).not.toHaveBeenCalled()
})
})
@@ -15,6 +15,7 @@
import serverCore, { TriggerControl } from '@hcengineering/server-core'
import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification'
import type { ReceiverInfo } from '@hcengineering/server-notification'
import {
AccountUuid,
Class,
@@ -49,6 +50,7 @@ import contact, {
} from '@hcengineering/contact'
import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index'
import { getPerson } from '@hcengineering/server-contact'
import { getAllowedProviders, getNotificationProviderControl, getReceiversInfo } from './utils'
async function createPushFromInbox (
control: TriggerControl,
@@ -235,25 +237,51 @@ export async function PushNotificationsHandler (
): Promise<Tx[]> {
const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map()
const all: InboxNotification[] = txes
.map((tx) => TxProcessor.createDoc2Doc(tx))
.filter(
(it) =>
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
)
const all: InboxNotification[] = txes.map((tx) => TxProcessor.createDoc2Doc(tx))
if (all.length === 0) {
// First pass: use cache if present.
const pushEnabled: InboxNotification[] = all.filter(
(it) =>
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
)
// Fallback: if cache doesn't have the provider info (e.g. scheduled notifications created outside tx-trigger paths),
// compute allowed providers from notification type + user settings.
if (pushEnabled.length < all.length) {
const notificationControl = await getNotificationProviderControl(control.ctx, control)
const receivers: ReceiverInfo[] = await getReceiversInfo(control.ctx, [...new Set(all.map((n) => n.user))], control)
const receiverByAccount = new Map(receivers.map((r) => [r.account, r]))
for (const n of all) {
if (availableProviders.get(n._id) !== undefined) continue
if (pushEnabled.includes(n)) continue
const type = (n.types ?? [])[0]
if (type === undefined) continue
const notificationType = control.modelDb.getObject(type)
const receiver = receiverByAccount.get(n.user)
if (receiver === undefined) continue
const allowedProviders = getAllowedProviders(control, receiver.socialIds, notificationType, notificationControl)
if (allowedProviders.includes(notification.providers.PushNotificationProvider)) {
pushEnabled.push(n)
}
}
}
if (pushEnabled.length === 0) {
return []
}
const receivers = new Set(all.map((it) => it.user))
const receivers = new Set(pushEnabled.map((it) => it.user))
const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) =>
receivers.has(it.user)
)
const res: Tx[] = []
for (const inboxNotification of all) {
for (const inboxNotification of pushEnabled) {
const { user } = inboxNotification
const userSubscriptions = subscriptions.filter((it) => it.user === user)