mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-11 12:17:44 +02:00
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user