diff --git a/README.md b/README.md index 2e9c44176d3..408cf42f04d 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ For detailed information about the platform architecture, services, and their in - [Docker](https://docs.docker.com/get-docker/) - [Docker Compose](https://docs.docker.com/compose/install/) +If you use `nvm`, run this after entering the repo to align your shell with the repository Node version: + +```bash +nvm use +``` + ## Verification To verify the installation, perform the following checks in your terminal: diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5f1981eebd5..0847adc30ab 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -40303,6 +40303,115 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../services/notification/pod-notification-scheduler: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.25 + version: link:../../../foundations/core/packages/account-client + '@hcengineering/analytics': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/analytics + '@hcengineering/analytics-service': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/analytics-service + '@hcengineering/api-client': + specifier: workspace:^0.7.25 + version: link:../../../foundations/core/packages/api-client + '@hcengineering/contact': + specifier: workspace:^0.7.0 + version: link:../../../plugins/contact + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../../foundations/core/packages/core + '@hcengineering/kafka': + specifier: workspace:^0.7.18 + version: link:../../../foundations/server/packages/kafka + '@hcengineering/model-time': + specifier: workspace:^0.7.0 + version: link:../../../models/time + '@hcengineering/notification': + specifier: workspace:^0.7.0 + version: link:../../../plugins/notification + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../../foundations/core/packages/platform + '@hcengineering/server-client': + specifier: workspace:^0.7.19 + version: link:../../../foundations/server/packages/client + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../../foundations/server/packages/core + '@hcengineering/server-token': + specifier: workspace:^0.7.18 + version: link:../../../foundations/core/packages/token + '@hcengineering/text-core': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/text-core + '@hcengineering/time': + specifier: workspace:^0.7.0 + version: link:../../../plugins/time + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../../foundations/utils/packages/platform-rig + '@tsconfig/node16': + specifier: ^1.0.4 + version: 1.0.4 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.18.1 + version: 22.19.0 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + cross-env: + specifier: ~7.0.3 + version: 7.0.3 + esbuild: + specifier: ^0.25.10 + version: 0.25.12 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../services/payment/pod-payment: dependencies: '@hcengineering/account-client': diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index 3f6de5a8283..57a54af7de1 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -58,5 +58,6 @@ else --to @hcengineering/pod-process \ --to @hcengineering/pod-rating \ --to @hcengineering/pod-payment \ - --to @hcengineering/pod-worker + --to @hcengineering/pod-worker \ + --to @hcengineering/pod-notification-scheduler fi diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 518614f92b1..3e6d8a38eda 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -612,6 +612,23 @@ services: - QUEUE_CONFIG=${QUEUE_CONFIG} - QUEUE_REGION=cockroach restart: unless-stopped + notification-scheduler: + image: hardcoreeng/notification-scheduler + extra_hosts: + - 'huly.local:host-gateway' + depends_on: + redpanda: + condition: service_started + account: + condition: service_started + environment: + - SERVICE_ID=notification-scheduler + - SECRET=secret + - ACCOUNTS_URL=http://huly.local:3000 + - QUEUE_CONFIG=${QUEUE_CONFIG} + - QUEUE_REGION=cockroach + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + restart: unless-stopped # translate: # image: hardcoreeng/translate # extra_hosts: diff --git a/models/time/src/index.ts b/models/time/src/index.ts index 4e64b04b083..2aa31803271 100644 --- a/models/time/src/index.ts +++ b/models/time/src/index.ts @@ -424,6 +424,41 @@ export function createModel (builder: Builder): void { enabledTypes: [time.ids.ToDoCreated] }) + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + hidden: false, + generated: false, + allowedForAuthor: true, + label: time.string.ToDo, + group: time.ids.TimeNotificationGroup as Ref, + // Scheduled notifications are created by a worker, but provider/type settings still expect a tx class list. + txClasses: [core.class.TxCreateDoc], + objectClass: time.class.ToDo, + onlyOwn: true, + defaultEnabled: true, + templates: { + textTemplate: '{body}', + htmlTemplate: '

{body}

{link}

', + subjectTemplate: '{title}' + } + }, + time.ids.ToDoReminder + ) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.InboxNotificationProvider, + ignoredTypes: [], + enabledTypes: [time.ids.ToDoReminder] + }) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.PushNotificationProvider, + ignoredTypes: [], + enabledTypes: [time.ids.ToDoReminder] + }) + builder.createDoc>(core.class.ClassCollaborators, core.space.Model, { attachedTo: time.class.ToDo, fields: ['user'] diff --git a/models/time/src/plugin.ts b/models/time/src/plugin.ts index 2aee0c9e42b..a8e1e1d3d96 100644 --- a/models/time/src/plugin.ts +++ b/models/time/src/plugin.ts @@ -58,7 +58,8 @@ export default mergeIds(timeId, time, { ids: { ToDoCreated: '' as Ref, ModulePermissionGroup: '' as Ref, - ModulePermissionGroupReadOnlyGuest: '' as Ref + ModulePermissionGroupReadOnlyGuest: '' as Ref, + ToDoReminder: '' as Ref }, function: { ToDoTitleProvider: '' as Resource<(client: Client, ref: Ref, doc?: Doc) => Promise> diff --git a/rush.json b/rush.json index 439e02a1b28..c0dc2f726dd 100644 --- a/rush.json +++ b/rush.json @@ -2221,6 +2221,11 @@ "projectFolder": "services/notification/pod-notification", "shouldPublish": false }, + { + "packageName": "@hcengineering/pod-notification-scheduler", + "projectFolder": "services/notification/pod-notification-scheduler", + "shouldPublish": false + }, { "packageName": "@hcengineering/pod-telegram", "projectFolder": "services/telegram/pod-telegram", diff --git a/server-plugins/notification-resources/src/push.ts b/server-plugins/notification-resources/src/push.ts index 7c522427330..a1d5890d1d7 100644 --- a/server-plugins/notification-resources/src/push.ts +++ b/server-plugins/notification-resources/src/push.ts @@ -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 { 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)) + + // 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 (all.length === 0) { + 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) diff --git a/server-plugins/time-resources/src/index.ts b/server-plugins/time-resources/src/index.ts index 494a84a4b88..590141a9564 100644 --- a/server-plugins/time-resources/src/index.ts +++ b/server-plugins/time-resources/src/index.ts @@ -36,7 +36,7 @@ import core, { } from '@hcengineering/core' import notification, { CommonInboxNotification } from '@hcengineering/notification' import { getResource } from '@hcengineering/platform' -import type { TriggerControl } from '@hcengineering/server-core' +import { QueueTopic, type TriggerControl } from '@hcengineering/server-core' import { getSocialStrings } from '@hcengineering/server-contact' import { ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' import { @@ -52,6 +52,108 @@ import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/t import time, { ProjectToDo, ToDo, ToDoPriority, TodoAutomationHelper, WorkSlot } from '@hcengineering/time' import tracker, { Issue, IssueStatus, Project, TimeSpendReport } from '@hcengineering/tracker' +const scheduledNotificationTopic = 'scheduledNotification' + +interface ScheduledNotificationMessage { + kind: 'todoReminder' + id: string + workSlotId: Ref + todoId: Ref + shiftMs: number + targetDate: number +} + +type TimeMachineMessage = + | { + type: 'schedule' + id: string + targetDate: number + topic: string + data: ScheduledNotificationMessage + } + | { + type: 'cancel' + id: string + } + +function workSlotReminderPrefix (workSlotId: Ref): string { + return `todoReminder_${workSlotId}_` +} + +function workSlotReminderTimerId (workSlotId: Ref, shiftMs: number): string { + // Make sure this is stable and easy to cancel with `${prefix}%`. + return `${workSlotReminderPrefix(workSlotId)}${shiftMs}` +} + +async function cancelWorkSlotReminders (control: TriggerControl, workSlotId: Ref): Promise { + try { + const queue = control.queue + if (queue === undefined) return + const producer = queue.getProducer(control.ctx, QueueTopic.TimeMachine) + await producer.send(control.ctx, control.workspace.uuid, [ + { + type: 'cancel', + id: `${workSlotReminderPrefix(workSlotId)}%` + } + ]) + } catch (err) { + control.ctx.error('Failed to cancel WorkSlot reminders', { err, workSlotId }) + } +} + +async function scheduleWorkSlotReminders (control: TriggerControl, workSlotId: Ref): Promise { + try { + const queue = control.queue + if (queue === undefined) return + + const workslot = (await control.findAll(control.ctx, time.class.WorkSlot, { _id: workSlotId }, { limit: 1 }))[0] + if (workslot === undefined) return + + // Reset existing timers for this WorkSlot on any relevant change. + await cancelWorkSlotReminders(control, workSlotId) + + const reminders = workslot.reminders ?? [] + if (reminders.length === 0) return + + // Skip scheduling for completed todos. + const todo = (await control.findAll(control.ctx, time.class.ToDo, { _id: workslot.attachedTo }, { limit: 1 }))[0] + if (todo === undefined) return + if (todo.doneOn != null) return + + const now = Date.now() + const msgs: TimeMachineMessage[] = [] + + for (const shiftMs of reminders) { + if (typeof shiftMs !== 'number' || Number.isNaN(shiftMs)) continue + const targetDate = workslot.date + shiftMs + if (targetDate <= now) continue + + const id = workSlotReminderTimerId(workSlotId, shiftMs) + const data: ScheduledNotificationMessage = { + kind: 'todoReminder', + id, + workSlotId, + todoId: todo._id, + shiftMs, + targetDate + } + msgs.push({ + type: 'schedule', + id, + targetDate, + topic: scheduledNotificationTopic, + data + }) + } + + if (msgs.length === 0) return + const producer = queue.getProducer(control.ctx, QueueTopic.TimeMachine) + await producer.send(control.ctx, control.workspace.uuid, msgs) + } catch (err) { + control.ctx.error('Failed to schedule WorkSlot reminders', { err, workSlotId }) + } +} + /** * @public */ @@ -88,6 +190,8 @@ export async function OnWorkSlotUpdate (txes: Tx[], control: TriggerControl): Pr } const updTx = actualTx as TxUpdateDoc const visibility = updTx.operations.visibility + const date = updTx.operations.date + const reminders = updTx.operations.reminders if (visibility !== undefined) { const workslot = ( await control.findAll(control.ctx, time.class.WorkSlot, { _id: updTx.objectId }, { limit: 1 }) @@ -98,6 +202,10 @@ export async function OnWorkSlotUpdate (txes: Tx[], control: TriggerControl): Pr const todo = (await control.findAll(control.ctx, time.class.ToDo, { _id: workslot.attachedTo }))[0] result.push(control.txFactory.createTxUpdateDoc(todo._class, todo.space, todo._id, { visibility })) } + + if (date !== undefined || reminders !== undefined) { + await scheduleWorkSlotReminders(control, updTx.objectId) + } } return result } @@ -112,6 +220,10 @@ export async function OnWorkSlotCreate (txes: Tx[], control: TriggerControl): Pr continue } const workslot = TxProcessor.createDoc2Doc(actualTx as TxCreateDoc) + + // Ensure reminders are scheduled immediately on create. + await scheduleWorkSlotReminders(control, workslot._id) + const workslots = await control.findAll(control.ctx, time.class.WorkSlot, { attachedTo: workslot.attachedTo }) if (workslots.length > 1) { continue @@ -165,6 +277,20 @@ export async function OnWorkSlotCreate (txes: Tx[], control: TriggerControl): Pr return [] } +export async function OnWorkSlotRemove (txes: Tx[], control: TriggerControl): Promise { + for (const tx of txes) { + const actualTx = tx as TxCUD + if (!control.hierarchy.isDerived(actualTx.objectClass, time.class.WorkSlot)) { + continue + } + if (!control.hierarchy.isDerived(actualTx._class, core.class.TxRemoveDoc)) { + continue + } + await cancelWorkSlotReminders(control, actualTx.objectId) + } + return [] +} + export async function OnToDoRemove (txes: Tx[], control: TriggerControl): Promise { for (const tx of txes) { const actualTx = tx as TxCUD @@ -373,6 +499,9 @@ export async function OnToDoUpdate (txes: Tx[], control: TriggerControl): Promis const events = await control.findAll(control.ctx, time.class.WorkSlot, { attachedTo: updTx.objectId }) const resEvents: WorkSlot[] = [] for (const event of events) { + // Cancel any pending reminder timers as soon as the todo is completed. + await cancelWorkSlotReminders(control, event._id) + if (event.date > doneOn) { const innerTx = control.txFactory.createTxRemoveDoc(event._class, event.space, event._id) const outerTx = control.txFactory.createTxCollectionCUD( @@ -792,6 +921,7 @@ export default async () => ({ OnToDoRemove, OnToDoCreate, OnWorkSlotCreate, - OnWorkSlotUpdate + OnWorkSlotUpdate, + OnWorkSlotRemove } }) diff --git a/server-plugins/time-resources/src/reminderScheduling.test.ts b/server-plugins/time-resources/src/reminderScheduling.test.ts new file mode 100644 index 00000000000..dfde2c75cd2 --- /dev/null +++ b/server-plugins/time-resources/src/reminderScheduling.test.ts @@ -0,0 +1,234 @@ +// +// 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 core, { generateId, type TxRemoveDoc, type TxUpdateDoc } from '@hcengineering/core' +import type { PlatformQueueProducer } from '@hcengineering/server-core' +import time, { type ToDo, type WorkSlot } from '@hcengineering/time' +import { OnToDoUpdate, OnWorkSlotRemove, OnWorkSlotUpdate } from './index' + +type AnyProducer = PlatformQueueProducer + +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 makeControlBase (overrides: Partial = {}): { 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('todo reminder scheduling (TimeMachine)', () => { + it('OnWorkSlotUpdate: schedules reminders when reminders change', async () => { + const workSlotId = generateId() + const todoId = generateId() + + const { control, send } = makeControlBase() + + // WorkSlot lookup inside scheduler. + ;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => { + if (_class === time.class.WorkSlot && query?._id === workSlotId) { + return [ + { + _id: workSlotId, + _class: time.class.WorkSlot, + space: core.space.Workspace, + attachedTo: todoId, + attachedToClass: time.class.ToDo, + date: Date.now() + 60_000, + reminders: [-5 * 60_000], + dueDate: Date.now() + 120_000 + } + ] + } + if (_class === time.class.ToDo && query?._id === todoId) { + return [ + { + _id: todoId, + _class: time.class.ToDo, + space: time.space.ToDos, + attachedTo: core.space.Workspace as any, + attachedToClass: core.class.Doc as any, + workslots: 1, + title: 't', + description: '', + priority: 0, + visibility: 'private', + doneOn: null, + user: generateId() as any, + rank: '' + } + ] + } + return [] + }) + + const tx = { + _id: generateId(), + _class: core.class.TxUpdateDoc, + objectId: workSlotId, + objectClass: time.class.WorkSlot, + objectSpace: core.space.Workspace, + space: core.space.Tx, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + operations: { reminders: [-5 * 60_000] } + } as unknown as TxUpdateDoc + + await OnWorkSlotUpdate([tx], control) + + // First call cancels `todoReminder__%`, second schedules the specific reminder. + expect(send).toHaveBeenCalled() + // Producer send signature: (ctx, workspace, msgs) + const msgs = (send.mock.calls).map((c: any[]) => c[2]).flat() + expect(msgs.find((m: any) => m.type === 'cancel')).toBeDefined() + expect(msgs.find((m: any) => m.type === 'schedule' && m.topic === 'scheduledNotification')).toBeDefined() + }) + + it('OnWorkSlotRemove: cancels reminders', async () => { + const workSlotId = generateId() + const { control, send } = makeControlBase() + + const tx = { + _id: generateId(), + _class: core.class.TxRemoveDoc, + objectId: workSlotId, + objectClass: time.class.WorkSlot, + objectSpace: core.space.Workspace, + space: core.space.Tx, + modifiedBy: core.account.System, + modifiedOn: Date.now() + } as unknown as TxRemoveDoc + + await OnWorkSlotRemove([tx], control) + + const msgs = (send.mock.calls).map((c: any[]) => c[2]).flat() + expect(msgs.find((m: any) => m.type === 'cancel')).toBeDefined() + }) + + it('OnToDoUpdate(doneOn): cancels reminders for all workslots', async () => { + const todoId = generateId() + const ws1 = generateId() + const ws2 = generateId() + + const { control, send } = makeControlBase() + + ;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => { + if (_class === time.class.ToDo && query?._id === todoId) { + return [ + { + _id: todoId, + _class: time.class.ToDo, + space: time.space.ToDos, + attachedTo: core.space.Workspace as any, + attachedToClass: core.class.Doc as any, + workslots: 2, + title: 't', + description: '', + priority: 0, + visibility: 'private', + doneOn: Date.now(), + user: generateId() as any, + rank: '' + } + ] + } + if (_class === core.class.TxUpdateDoc && query?.objectId === todoId) { + return [] + } + if (_class === time.class.WorkSlot && query?.attachedTo === todoId) { + return [ + { + _id: ws1, + _class: time.class.WorkSlot, + space: core.space.Workspace, + attachedTo: todoId, + attachedToClass: time.class.ToDo, + date: Date.now() + 60_000, + dueDate: Date.now() + 120_000, + reminders: [-5 * 60_000] + }, + { + _id: ws2, + _class: time.class.WorkSlot, + space: core.space.Workspace, + attachedTo: todoId, + attachedToClass: time.class.ToDo, + date: Date.now() + 60_000, + dueDate: Date.now() + 120_000, + reminders: [-5 * 60_000] + } + ] + } + return [] + }) + + const tx = { + _id: generateId(), + _class: core.class.TxUpdateDoc, + objectId: todoId, + objectClass: time.class.ToDo, + objectSpace: time.space.ToDos, + space: core.space.Tx, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + operations: { doneOn: Date.now() } + } as unknown as TxUpdateDoc + + await OnToDoUpdate([tx], control) + + const msgs = (send.mock.calls).map((c: any[]) => c[2]).flat() + const cancelMsgs = msgs.filter((m: any) => m.type === 'cancel') + expect(cancelMsgs.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/services/notification/pod-notification-scheduler/.eslintrc.js b/services/notification/pod-notification-scheduler/.eslintrc.js new file mode 100644 index 00000000000..6ab3cb53db3 --- /dev/null +++ b/services/notification/pod-notification-scheduler/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} + diff --git a/services/notification/pod-notification-scheduler/Dockerfile b/services/notification/pod-notification-scheduler/Dockerfile new file mode 100644 index 00000000000..4f2f62f5d60 --- /dev/null +++ b/services/notification/pod-notification-scheduler/Dockerfile @@ -0,0 +1,6 @@ +FROM hardcoreeng/base-slim:v20250916 +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ + +CMD [ "node", "bundle.js" ] diff --git a/services/notification/pod-notification-scheduler/jest.config.js b/services/notification/pod-notification-scheduler/jest.config.js new file mode 100644 index 00000000000..7929244e79d --- /dev/null +++ b/services/notification/pod-notification-scheduler/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + coverageReporters: ['text-summary', 'html'] +} + diff --git a/services/notification/pod-notification-scheduler/package.json b/services/notification/pod-notification-scheduler/package.json new file mode 100644 index 00000000000..33fc7394096 --- /dev/null +++ b/services/notification/pod-notification-scheduler/package.json @@ -0,0 +1,71 @@ +{ + "name": "@hcengineering/pod-notification-scheduler", + "version": "0.7.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/notification-scheduler", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/notification-scheduler staging", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/notification-scheduler", + "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": "workspace:^0.7.21", + "@tsconfig/node16": "^1.0.4", + "@types/node": "^22.18.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/parser": "^6.21.0", + "cross-env": "~7.0.3", + "esbuild": "^0.25.10", + "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.6.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "@hcengineering/account-client": "workspace:^0.7.25", + "@hcengineering/analytics": "workspace:^0.7.19", + "@hcengineering/analytics-service": "workspace:^0.7.19", + "@hcengineering/api-client": "workspace:^0.7.25", + "@hcengineering/contact": "workspace:^0.7.0", + "@hcengineering/core": "workspace:^0.7.26", + "@hcengineering/kafka": "workspace:^0.7.18", + "@hcengineering/model-time": "workspace:^0.7.0", + "@hcengineering/notification": "workspace:^0.7.0", + "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/server-client": "workspace:^0.7.19", + "@hcengineering/server-core": "workspace:^0.7.19", + "@hcengineering/server-token": "workspace:^0.7.18", + "@hcengineering/text-core": "workspace:^0.7.19", + "@hcengineering/time": "workspace:^0.7.0", + "dotenv": "^16.4.5" + } +} + diff --git a/services/notification/pod-notification-scheduler/src/client.ts b/services/notification/pod-notification-scheduler/src/client.ts new file mode 100644 index 00000000000..bf402e22a57 --- /dev/null +++ b/services/notification/pod-notification-scheduler/src/client.ts @@ -0,0 +1,47 @@ +// +// 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 { getClient as getAccountClient, type AccountClient } from '@hcengineering/account-client' +import { createRestTxOperations } from '@hcengineering/api-client' +import core, { PersonId, systemAccountUuid, type TxOperations, type WorkspaceUuid } from '@hcengineering/core' +import { generateToken } from '@hcengineering/server-token' +import config from './config' + +export async function getClient ( + workspaceUuid: WorkspaceUuid, + socialId?: PersonId, + serviceTag: string = config.ServiceId +): Promise<{ client: TxOperations, accountClient: AccountClient }> { + const token = generateToken(systemAccountUuid, workspaceUuid, { service: serviceTag }) + let accountClient = getAccountClient(config.AccountsUrl, token) + + // If we want the notification author to be a specific user, we can obtain a workspace token for that person. + if (socialId !== undefined && socialId !== core.account.System) { + const personUuid = await accountClient.findPersonBySocialId(socialId, true) + if (personUuid === undefined) { + throw new Error(`Global person not found for social-id ${socialId}`) + } + const token = generateToken(personUuid, workspaceUuid, { service: serviceTag }) + accountClient = getAccountClient(config.AccountsUrl, token) + } + + const wsInfo = await accountClient.getLoginInfoByToken() + if (wsInfo == null || !('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 } +} diff --git a/services/notification/pod-notification-scheduler/src/config.ts b/services/notification/pod-notification-scheduler/src/config.ts new file mode 100644 index 00000000000..2fb663394ed --- /dev/null +++ b/services/notification/pod-notification-scheduler/src/config.ts @@ -0,0 +1,34 @@ +// +// 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 { config as dotenvConfig } from 'dotenv' + +dotenvConfig() + +interface Config { + ServiceId: string + Secret: string + AccountsUrl: string + QueueRegion: string +} + +const config: Config = { + ServiceId: process.env.SERVICE_ID ?? 'notification-scheduler', + Secret: process.env.SECRET ?? 'secret', + AccountsUrl: process.env.ACCOUNTS_URL ?? 'http://localhost:3000', + QueueRegion: process.env.QUEUE_REGION ?? 'localhost' +} + +export default config diff --git a/services/notification/pod-notification-scheduler/src/index.ts b/services/notification/pod-notification-scheduler/src/index.ts new file mode 100644 index 00000000000..64092121d36 --- /dev/null +++ b/services/notification/pod-notification-scheduler/src/index.ts @@ -0,0 +1,80 @@ +// +// 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 { Analytics } from '@hcengineering/analytics' +import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' +import { newMetrics } from '@hcengineering/core' +import { getPlatformQueue } from '@hcengineering/kafka' +import { setMetadata } from '@hcengineering/platform' +import serverClient from '@hcengineering/server-client' +import { initStatisticsContext } from '@hcengineering/server-core' +import serverToken from '@hcengineering/server-token' +import { join } from 'path' +import config from './config' +import type { ScheduledNotificationMessage } from './types' +import { handleScheduledNotification } from './worker' + +const scheduledNotificationTopic = 'scheduledNotification' + +async function main (): Promise { + configureAnalytics(config.ServiceId, process.env.VERSION ?? '0.7.0') + const ctx = initStatisticsContext(config.ServiceId, { + factory: () => + createOpenTelemetryMetricsContext( + config.ServiceId, + {}, + {}, + newMetrics(), + new SplitLogger(config.ServiceId, { + root: join(process.cwd(), 'logs'), + enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' + }) + ) + }) + + Analytics.setTag('application', config.ServiceId) + setMetadata(serverToken.metadata.Secret, config.Secret) + setMetadata(serverToken.metadata.Service, config.ServiceId) + setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl) + + const queue = getPlatformQueue(config.ServiceId, config.QueueRegion) + + const consumer = queue.createConsumer( + ctx, + scheduledNotificationTopic, + queue.getClientId(), + async (ctx, message) => { + await handleScheduledNotification(ctx, message.workspace, message.value) + } + ) + + 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) => { + // eslint-disable-next-line no-console + console.error(err) +}) diff --git a/services/notification/pod-notification-scheduler/src/types.ts b/services/notification/pod-notification-scheduler/src/types.ts new file mode 100644 index 00000000000..e029417a684 --- /dev/null +++ b/services/notification/pod-notification-scheduler/src/types.ts @@ -0,0 +1,26 @@ +// +// 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 type { Ref } from '@hcengineering/core' +import type { WorkSlot, ToDo } from '@hcengineering/time' + +export interface ScheduledNotificationMessage { + kind: 'todoReminder' + id: string + workSlotId: Ref + todoId: Ref + shiftMs: number + targetDate: number +} diff --git a/services/notification/pod-notification-scheduler/src/worker.ts b/services/notification/pod-notification-scheduler/src/worker.ts new file mode 100644 index 00000000000..eae52f0a2f2 --- /dev/null +++ b/services/notification/pod-notification-scheduler/src/worker.ts @@ -0,0 +1,104 @@ +// +// 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 contact from '@hcengineering/contact' +import { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core' +import notification from '@hcengineering/notification' +import modelTime from '@hcengineering/model-time' +import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/text-core' +import time from '@hcengineering/time' +import { getClient } from './client' +import type { ScheduledNotificationMessage } from './types' + +export async function handleScheduledNotification ( + ctx: MeasureContext, + workspaceUuid: WorkspaceUuid, + msg: ScheduledNotificationMessage +): Promise { + if (msg.kind !== 'todoReminder') return + + const { client } = await getClient(workspaceUuid) + + const workslot = await client.findOne(time.class.WorkSlot, { _id: msg.workSlotId }) + if (workslot === undefined) return + const todo = await client.findOne(time.class.ToDo, { _id: msg.todoId }) + if (todo === undefined) return + if (todo.doneOn != null) return + + const employee = await client.findOne(contact.mixin.Employee, { _id: todo.user, active: true }) + if (employee?.personUuid == null) return + const user = employee.personUuid + + const space = await client.findOne(contact.class.PersonSpace, { person: todo.user }, { projection: { _id: 1 } }) + if (space === undefined) return + + const objectId = todo._id + const objectClass = todo._class + const objectSpace = todo.space + + // Ensure doc notify context exists. + let docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { objectId, user }) + if (docNotifyContext === undefined) { + const id = await client.createDoc(notification.class.DocNotifyContext, space._id, { + objectId, + objectClass, + objectSpace, + user, + isPinned: false, + hidden: false + }) + docNotifyContext = await client.findOne( + notification.class.DocNotifyContext, + { _id: id }, + { projection: { _id: 1 } } + ) + if (docNotifyContext === undefined) return + } + + // Idempotency: if this timer already created a notification, skip. + // We key by (docNotifyContext,user,msg.id) via intlParams; this avoids needing deterministic _id. + const existing = await client.findOne(notification.class.CommonInboxNotification, { + docNotifyContext: docNotifyContext._id, + user, + 'intlParams.timerId': msg.id + } as any) + if (existing !== undefined) return + + await client.createDoc(notification.class.CommonInboxNotification, space._id, { + user, + objectId, + objectClass, + icon: time.icon.Planned, + header: time.string.ToDo, + message: time.string.ToDo, + messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(todo.title)))), + intlParams: { + timerId: msg.id + }, + types: [modelTime.ids.ToDoReminder], + isViewed: false, + archived: false, + docNotifyContext: docNotifyContext._id + } as any) + + ctx.info('Scheduled notification created', { + kind: msg.kind, + id: msg.id, + workSlotId: msg.workSlotId, + todoId: msg.todoId, + user, + spaceId: space._id + }) +} diff --git a/services/notification/pod-notification-scheduler/tsconfig.json b/services/notification/pod-notification-scheduler/tsconfig.json new file mode 100644 index 00000000000..41f3dcae2c9 --- /dev/null +++ b/services/notification/pod-notification-scheduler/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} +