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
+2 -1
View File
@@ -113,4 +113,5 @@ dev/tool/history.json
/combined_dependencies /combined_dependencies
.tmp .tmp
ws-tests/docker-compose.override.yml ws-tests/docker-compose.override.yml
.cursor/* .cursor/*
CLAUDE.md
+6
View File
@@ -99,6 +99,12 @@ For detailed information about the platform architecture, services, and their in
- [Docker](https://docs.docker.com/get-docker/) - [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/) - [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 ## Verification
To verify the installation, perform the following checks in your terminal: To verify the installation, perform the following checks in your terminal:
+109
View File
@@ -40218,6 +40218,115 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
../../services/notification/pod-events-processor:
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/calendar':
specifier: workspace:^0.7.0
version: link:../../../plugins/calendar
'@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/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.16
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/notification/pod-notification: ../../services/notification/pod-notification:
dependencies: dependencies:
'@hcengineering/analytics': '@hcengineering/analytics':
+2 -1
View File
@@ -58,5 +58,6 @@ else
--to @hcengineering/pod-process \ --to @hcengineering/pod-process \
--to @hcengineering/pod-rating \ --to @hcengineering/pod-rating \
--to @hcengineering/pod-payment \ --to @hcengineering/pod-payment \
--to @hcengineering/pod-worker --to @hcengineering/pod-worker \
--to @hcengineering/pod-events-processor
fi fi
+18
View File
@@ -595,6 +595,24 @@ services:
- QUEUE_CONFIG=${QUEUE_CONFIG} - QUEUE_CONFIG=${QUEUE_CONFIG}
- QUEUE_REGION=cockroach - QUEUE_REGION=cockroach
restart: unless-stopped restart: unless-stopped
events-processor:
image: hardcoreeng/events-processor
extra_hosts:
- 'huly.local:host-gateway'
depends_on:
redpanda:
condition: service_started
account:
condition: service_started
environment:
- SERVICE_ID=events-processor
- LOG_LEVEL=debug
- 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: # translate:
# image: hardcoreeng/translate # image: hardcoreeng/translate
# extra_hosts: # extra_hosts:
@@ -16,7 +16,7 @@ export class SplitLogger implements MeasureLogger {
const rootDir = this.opts.root ?? 'logs' const rootDir = this.opts.root ?? 'logs'
this.logger = winston.createLogger({ this.logger = winston.createLogger({
level: 'info', level: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',
exitOnError: false exitOnError: false
}) })
const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ const errorPrinter = ({ message, stack, ...rest }: Error): object => ({
@@ -99,6 +99,13 @@ export class SplitLogger implements MeasureLogger {
this.logger.warn({ message, ...obj }) this.logger.warn({ message, ...obj })
} }
debug (message: string, obj?: Record<string, any>): void {
if (this.opts.parent !== undefined) {
this.opts.parent.debug({ message, ...obj })
}
this.logger.debug({ message, ...obj })
}
logOperation (operation: string, time: number, params: ParamsType): void { logOperation (operation: string, time: number, params: ParamsType): void {
this.logger.info(operation, { time, ...params }) this.logger.info(operation, { time, ...params })
} }
@@ -22,6 +22,7 @@ describe('telemetry', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}), close: jest.fn(async () => {}),
logOperation: jest.fn() logOperation: jest.fn()
} }
@@ -11,6 +11,7 @@ import {
updateMeasure, updateMeasure,
type FullParamsType, type FullParamsType,
type MeasureLogger, type MeasureLogger,
type MeasureLogLevel,
type Metrics, type Metrics,
type ParamsType, type ParamsType,
type WithOptions type WithOptions
@@ -97,7 +98,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
readonly logParams?: ParamsType, readonly logParams?: ParamsType,
readonly otlpLogger?: Logger, readonly otlpLogger?: Logger,
readonly meter?: MetricsContext readonly meter?: MetricsContext,
readonly logLevel: MeasureLogLevel = 'info'
) { ) {
this.name = name this.name = name
this.params = params this.params = params
@@ -133,6 +135,7 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
logger?: MeasureLogger logger?: MeasureLogger
span?: WithOptions['span'] // By default true span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean> meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
} }
): MeasureContext { ): MeasureContext {
let _span: Span | undefined let _span: Span | undefined
@@ -170,7 +173,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
this, this,
this.logParams, this.logParams,
this.otlpLogger, this.otlpLogger,
this.meter this.meter,
opt?.logLevel ?? this.logLevel
) )
result.id = this.id result.id = this.id
result.contextData = this.contextData result.contextData = this.contextData
@@ -309,6 +313,23 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
} }
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
if (this.otlpLogger !== undefined) {
this.otlpLogger.emit({
severityNumber: SeverityNumber.DEBUG,
severityText: 'debug',
context: this.context,
body: message,
attributes: {
'service.name': sdkServiceName,
...(args ?? {})
}
})
}
this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void { end (): void {
this.done() this.done()
} }
@@ -530,7 +551,8 @@ export function createOpenTelemetryMetricsContext (
): MeasureContext { ): MeasureContext {
if (!initOpenTelemetrySDK(name, version ?? '')) { if (!initOpenTelemetrySDK(name, version ?? '')) {
console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent') console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent')
return new MeasureMetricsContext(name, params, fullParams, metrics, logger) const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'
return new MeasureMetricsContext(name, params, fullParams, metrics, logger, undefined, undefined, rootLogLevel)
} }
// Traces // Traces
@@ -542,6 +564,8 @@ export function createOpenTelemetryMetricsContext (
const meter = otelMetrics.getMeter(name, version) const meter = otelMetrics.getMeter(name, version)
const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'
const ctx = new OpenTelemetryMetricsContext( const ctx = new OpenTelemetryMetricsContext(
name, name,
tracer, tracer,
@@ -554,7 +578,8 @@ export function createOpenTelemetryMetricsContext (
undefined, undefined,
undefined, undefined,
otlpLogger, otlpLogger,
new MetricsContext(meter) new MetricsContext(meter),
rootLogLevel
) )
return ctx return ctx
} }
@@ -21,6 +21,7 @@ describe('context', () => {
expect(typeof logger.info).toBe('function') expect(typeof logger.info).toBe('function')
expect(typeof logger.error).toBe('function') expect(typeof logger.error).toBe('function')
expect(typeof logger.warn).toBe('function') expect(typeof logger.warn).toBe('function')
expect(typeof logger.debug).toBe('function')
expect(typeof logger.close).toBe('function') expect(typeof logger.close).toBe('function')
}) })
@@ -54,6 +55,16 @@ describe('context', () => {
consoleSpy.mockRestore() consoleSpy.mockRestore()
}) })
it('should log debug messages', () => {
const consoleSpy = jest.spyOn(console, 'debug').mockImplementation()
const logger = consoleLogger({ service: 'test' })
logger.debug('Debug message', { detail: 'x' })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should handle errors in params', () => { it('should handle errors in params', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation() const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const logger = consoleLogger({}) const logger = consoleLogger({})
@@ -145,6 +156,7 @@ describe('context', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}), close: jest.fn(async () => {}),
logOperation: jest.fn() logOperation: jest.fn()
} }
@@ -163,6 +175,7 @@ describe('context', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}), close: jest.fn(async () => {}),
logOperation: jest.fn() logOperation: jest.fn()
} }
@@ -181,6 +194,7 @@ describe('context', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}), close: jest.fn(async () => {}),
logOperation: jest.fn() logOperation: jest.fn()
} }
@@ -194,6 +208,47 @@ describe('context', () => {
) )
}) })
it('should not log debug when log level is info', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger)
ctx.debug('Skip', { k: 1 })
expect(mockLogger.debug).not.toHaveBeenCalled()
})
it('should log debug when log level is debug', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext(
'test',
{ op: 'test' },
{},
newMetrics(),
mockLogger,
undefined,
undefined,
'debug'
)
ctx.debug('D', { k: 1 })
expect(mockLogger.debug).toHaveBeenCalledWith('D', expect.objectContaining({ k: 1, op: 'test' }))
})
it('should get params', () => { it('should get params', () => {
const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger) const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger)
const params = ctx.getParams() const params = ctx.getParams()
@@ -440,6 +495,7 @@ describe('context', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}), close: jest.fn(async () => {}),
logOperation: jest.fn() logOperation: jest.fn()
} }
@@ -6,6 +6,7 @@ import {
type FullParamsType, type FullParamsType,
type MeasureContext, type MeasureContext,
type MeasureLogger, type MeasureLogger,
type MeasureLogLevel,
type Metrics, type Metrics,
type ParamsType, type ParamsType,
type OperationLog, type OperationLog,
@@ -42,6 +43,14 @@ export const consoleLogger = (logParams: Record<string, any>): MeasureLogger =>
warn: (msg, args) => { warn: (msg, args) => {
console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`)) console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`))
}, },
debug: (msg, args) => {
console.debug(
msg,
...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map(
(it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`
)
)
},
close: async () => {}, close: async () => {},
logOperation: (operation, time, params) => {} logOperation: (operation, time, params) => {}
}) })
@@ -62,6 +71,8 @@ export class MeasureMetricsContext implements MeasureContext {
metrics: Metrics metrics: Metrics
id?: string id?: string
private readonly logLevel: MeasureLogLevel
st = platformNow() st = platformNow()
contextData: object = {} contextData: object = {}
private done (value?: number, override?: boolean): void { private done (value?: number, override?: boolean): void {
@@ -75,12 +86,14 @@ export class MeasureMetricsContext implements MeasureContext {
metrics: Metrics = newMetrics(), metrics: Metrics = newMetrics(),
logger?: MeasureLogger, logger?: MeasureLogger,
readonly parent?: MeasureContext, readonly parent?: MeasureContext,
readonly logParams?: ParamsType readonly logParams?: ParamsType,
logLevel: MeasureLogLevel = 'info'
) { ) {
this.name = name this.name = name
this.params = params this.params = params
this.fullParams = fullParams this.fullParams = fullParams
this.metrics = metrics this.metrics = metrics
this.logLevel = logLevel
this.metrics.namedParams = this.metrics.namedParams ?? {} this.metrics.namedParams = this.metrics.namedParams ?? {}
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
if (this.metrics.namedParams[k] !== v) { if (this.metrics.namedParams[k] !== v) {
@@ -94,7 +107,16 @@ export class MeasureMetricsContext implements MeasureContext {
} }
measure (name: string, value: number, override?: boolean): void { measure (name: string, value: number, override?: boolean): void {
const c = new MeasureMetricsContext('#' + name, {}, {}, childMetrics(this.metrics, ['#' + name]), this.logger, this) const c = new MeasureMetricsContext(
'#' + name,
{},
{},
childMetrics(this.metrics, ['#' + name]),
this.logger,
this,
undefined,
this.logLevel
)
c.contextData = this.contextData c.contextData = this.contextData
c.done(value, override) c.done(value, override)
} }
@@ -106,6 +128,8 @@ export class MeasureMetricsContext implements MeasureContext {
fullParams?: FullParamsType fullParams?: FullParamsType
logger?: MeasureLogger logger?: MeasureLogger
span?: WithOptions['span'] // By default true span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
} }
): MeasureContext { ): MeasureContext {
const result = new MeasureMetricsContext( const result = new MeasureMetricsContext(
@@ -115,7 +139,8 @@ export class MeasureMetricsContext implements MeasureContext {
childMetrics(this.metrics, [name]), childMetrics(this.metrics, [name]),
opt?.logger ?? this.logger, opt?.logger ?? this.logger,
this, this,
this.logParams this.logParams,
opt?.logLevel ?? this.logLevel
) )
result.id = this.id result.id = this.id
result.contextData = this.contextData result.contextData = this.contextData
@@ -187,6 +212,11 @@ export class MeasureMetricsContext implements MeasureContext {
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
} }
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void { end (): void {
this.done() this.done()
} }
@@ -202,8 +232,11 @@ export class NoMetricsContext implements MeasureContext {
contextData: object = {} contextData: object = {}
constructor (logger?: MeasureLogger) { private readonly logLevel: MeasureLogLevel
constructor (logger?: MeasureLogger, logLevel: MeasureLogLevel = 'info') {
this.logger = logger ?? consoleLogger({}) this.logger = logger ?? consoleLogger({})
this.logLevel = logLevel
} }
measure (name: string, value: number, override?: boolean): void {} measure (name: string, value: number, override?: boolean): void {}
@@ -211,10 +244,15 @@ export class NoMetricsContext implements MeasureContext {
newChild ( newChild (
name: string, name: string,
params: ParamsType, params: ParamsType,
fullParams?: FullParamsType | (() => FullParamsType), opt?: {
logger?: MeasureLogger fullParams?: FullParamsType | (() => FullParamsType)
logger?: MeasureLogger
span?: WithOptions['span']
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
}
): MeasureContext { ): MeasureContext {
const result = new NoMetricsContext(logger ?? this.logger) const result = new NoMetricsContext(opt?.logger ?? this.logger, opt?.logLevel ?? this.logLevel)
result.id = this.id result.id = this.id
result.contextData = this.contextData result.contextData = this.contextData
return result return result
@@ -226,7 +264,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T | Promise<T>, op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType | (() => FullParamsType) fullParams?: ParamsType | (() => FullParamsType)
): Promise<T> { ): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger)) const r = op(this.newChild(name, params, { fullParams, logger: this.logger }))
return r instanceof Promise ? r : Promise.resolve(r) return r instanceof Promise ? r : Promise.resolve(r)
} }
@@ -240,7 +278,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T, op: (ctx: MeasureContext) => T,
fullParams?: ParamsType | (() => FullParamsType) fullParams?: ParamsType | (() => FullParamsType)
): T { ): T {
const c = this.newChild(name, params, fullParams, this.logger) const c = this.newChild(name, params, { fullParams, logger: this.logger })
return op(c) return op(c)
} }
@@ -250,7 +288,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T | Promise<T>, op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType fullParams?: ParamsType
): Promise<T> { ): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger)) const r = op(this.newChild(name, params, { fullParams, logger: this.logger }))
return r instanceof Promise ? r : Promise.resolve(r) return r instanceof Promise ? r : Promise.resolve(r)
} }
@@ -266,6 +304,11 @@ export class NoMetricsContext implements MeasureContext {
this.logger.warn(message, { ...args }) this.logger.warn(message, { ...args })
} }
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
this.logger.debug(message, { ...args })
}
end (): void {} end (): void {}
getParams (): ParamsType { getParams (): ParamsType {
@@ -49,6 +49,12 @@ export interface Metrics extends MetricsData {
opLog?: Record<string, OperationLog> opLog?: Record<string, OperationLog>
} }
/**
* Root log verbosity for {@link MeasureContext.debug} (child contexts inherit unless overridden in {@link MeasureContext.newChild}).
* @public
*/
export type MeasureLogLevel = 'info' | 'debug'
/** /**
* @public * @public
*/ */
@@ -58,6 +64,8 @@ export interface MeasureLogger {
warn: (message: string, obj?: Record<string, any>) => void warn: (message: string, obj?: Record<string, any>) => void
debug: (message: string, obj?: Record<string, any>) => void
logOperation: (operation: string, time: number, params: ParamsType) => void logOperation: (operation: string, time: number, params: ParamsType) => void
childLogger?: (name: string, params: Record<string, any>) => MeasureLogger childLogger?: (name: string, params: Record<string, any>) => MeasureLogger
@@ -94,6 +102,7 @@ export interface MeasureContext<Q = any> {
logger?: MeasureLogger logger?: MeasureLogger
span?: WithOptions['span'] // By default true span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean> meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
} }
) => MeasureContext ) => MeasureContext
@@ -128,6 +137,7 @@ export interface MeasureContext<Q = any> {
error: (message: string, obj?: Record<string, any>) => void error: (message: string, obj?: Record<string, any>) => void
info: (message: string, obj?: Record<string, any>) => void info: (message: string, obj?: Record<string, any>) => void
warn: (message: string, obj?: Record<string, any>) => void warn: (message: string, obj?: Record<string, any>) => void
debug: (message: string, obj?: Record<string, any>) => void // No-op unless this context was created with log level `debug`.
// Mark current context as complete // Mark current context as complete
// If no value is passed, time difference will be used. // If no value is passed, time difference will be used.
@@ -150,6 +150,7 @@ const mockContext: MeasureContext = {
info: jest.fn(), info: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
error: jest.fn(), error: jest.fn(),
debug: jest.fn(),
with: jest.fn().mockImplementation((name, params, fn) => fn()), with: jest.fn().mockImplementation((name, params, fn) => fn()),
withSync: jest.fn().mockImplementation((name, params, fn) => fn()), withSync: jest.fn().mockImplementation((name, params, fn) => fn()),
measure: jest.fn(), measure: jest.fn(),
@@ -259,9 +259,14 @@ class PlatformQueueConsumerImpl implements ConsumerHandle {
maxRetryDelay?: number // Maximum retry delay in seconds (default 10) maxRetryDelay?: number // Maximum retry delay in seconds (default 10)
} }
) { ) {
// Long handlers must call ConsumerControl.heartbeat(); these timeouts still help under broker/load jitter (e.g. Redpanda in Docker).
const sessionTimeout = parseInt(process.env.KAFKA_CONSUMER_SESSION_TIMEOUT_MS ?? '90000', 10)
this.cc = this.kafka.consumer({ this.cc = this.kafka.consumer({
groupId: `${getKafkaTopicId(this.topic, this.config)}-${groupId}`, groupId: `${getKafkaTopicId(this.topic, this.config)}-${groupId}`,
allowAutoTopicCreation: true allowAutoTopicCreation: true,
sessionTimeout,
rebalanceTimeout: Math.min(sessionTimeout * 2, 300000),
heartbeatInterval: 3000
}) })
void this.start().catch((err) => { void this.start().catch((err) => {
+16 -7
View File
@@ -279,17 +279,20 @@ export function createModel (builder: Builder): void {
{ {
hidden: false, hidden: false,
generated: false, generated: false,
allowedForAuthor: true,
label: calendar.string.Reminder, label: calendar.string.Reminder,
group: calendar.ids.CalendarNotificationGroup, group: calendar.ids.CalendarNotificationGroup,
txClasses: [], // Scheduled reminders are created by the events-processor worker, but provider/type settings still expect a
// tx class list. The notification doc itself is materialized via a direct createDoc, not by a tx trigger.
txClasses: [core.class.TxCreateDoc],
objectClass: calendar.class.Event, objectClass: calendar.class.Event,
allowedForAuthor: true, onlyOwn: true,
defaultEnabled: true,
templates: { templates: {
textTemplate: 'Reminder: {doc}', textTemplate: '{body}',
htmlTemplate: 'Reminder: {doc}', htmlTemplate: '<p>{body}</p><p>{link}</p>',
subjectTemplate: 'Reminder: {doc}' subjectTemplate: '{title}'
}, }
defaultEnabled: false
}, },
calendar.ids.ReminderNotification calendar.ids.ReminderNotification
) )
@@ -300,6 +303,12 @@ export function createModel (builder: Builder): void {
enabledTypes: [calendar.ids.ReminderNotification] enabledTypes: [calendar.ids.ReminderNotification]
}) })
builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, {
provider: notification.providers.PushNotificationProvider,
ignoredTypes: [],
enabledTypes: [calendar.ids.ReminderNotification]
})
builder.createDoc( builder.createDoc(
activity.class.DocUpdateMessageViewlet, activity.class.DocUpdateMessageViewlet,
core.space.Model, core.space.Model,
-1
View File
@@ -55,7 +55,6 @@ export default mergeIds(calendarId, calendar, {
string: { string: {
ApplicationLabelCalendar: '' as IntlString, ApplicationLabelCalendar: '' as IntlString,
Event: '' as IntlString, Event: '' as IntlString,
Reminder: '' as IntlString,
Shift: '' as IntlString, Shift: '' as IntlString,
State: '' as IntlString, State: '' as IntlString,
CreatedReminder: '' as IntlString, CreatedReminder: '' as IntlString,
+1
View File
@@ -257,6 +257,7 @@ const calendarPlugin = plugin(calendarId, {
PersonsLabel: '' as IntlString, PersonsLabel: '' as IntlString,
EventNumber: '' as IntlString, EventNumber: '' as IntlString,
Reminders: '' as IntlString, Reminders: '' as IntlString,
Reminder: '' as IntlString,
Today: '' as IntlString, Today: '' as IntlString,
Visibility: '' as IntlString, Visibility: '' as IntlString,
Public: '' as IntlString, Public: '' as IntlString,
@@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import { AccessLevel, Calendar, generateEventId } from '@hcengineering/calendar' import { AccessLevel, Calendar, generateEventId } from '@hcengineering/calendar'
import { VisibilityEditor } from '@hcengineering/calendar-resources' import { EventReminders, VisibilityEditor } from '@hcengineering/calendar-resources'
import calendar from '@hcengineering/calendar-resources/src/plugin' import calendar from '@hcengineering/calendar-resources/src/plugin'
import { getCurrentEmployee } from '@hcengineering/contact' import { getCurrentEmployee } from '@hcengineering/contact'
import core, { AttachedData, Doc, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core' import core, { AttachedData, Doc, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core'
@@ -104,7 +104,7 @@
allDay: false, allDay: false,
access: AccessLevel.Owner, access: AccessLevel.Owner,
visibility: todo.visibility === 'public' ? 'public' : 'freeBusy', visibility: todo.visibility === 'public' ? 'public' : 'freeBusy',
reminders: [], reminders,
user: myAccount.primarySocialId user: myAccount.primarySocialId
}) })
Analytics.handleEvent(TimeEvents.ToDoScheduled, { id }) Analytics.handleEvent(TimeEvents.ToDoScheduled, { id })
@@ -126,6 +126,7 @@
}) })
let slots: WorkSlot[] = [] let slots: WorkSlot[] = []
let reminders: number[] = []
function removeSlot (e: CustomEvent<{ _id: Ref<WorkSlot> }>): void { function removeSlot (e: CustomEvent<{ _id: Ref<WorkSlot> }>): void {
const index = slots.findIndex((p) => p._id === e.detail._id) const index = slots.findIndex((p) => p._id === e.detail._id)
@@ -151,7 +152,7 @@
blockTime: true, blockTime: true,
access: AccessLevel.Owner, access: AccessLevel.Owner,
visibility: todo.visibility, visibility: todo.visibility,
reminders: [], reminders,
calendar: _calendar, calendar: _calendar,
space: calendar.space.Calendar, space: calendar.space.Calendar,
_id: generateId(), _id: generateId(),
@@ -267,6 +268,9 @@
on:change={changeSlot} on:change={changeSlot}
on:dueChange={changeDueSlot} on:dueChange={changeDueSlot}
/> />
{#if slots.length > 0}
<EventReminders bind:reminders />
{/if}
</div> </div>
<div class="flex-row-reverse btn flex-no-shrink"> <div class="flex-row-reverse btn flex-no-shrink">
<Button <Button
@@ -13,7 +13,8 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import calendar, { AccessLevel, Calendar, generateEventId } from '@hcengineering/calendar' import calendar, { AccessLevel, generateEventId } from '@hcengineering/calendar'
import { EventReminders } from '@hcengineering/calendar-resources'
import contact, { getCurrentEmployee } from '@hcengineering/contact' import contact, { getCurrentEmployee } from '@hcengineering/contact'
import { Ref, getCurrentAccount } from '@hcengineering/core' import { Ref, getCurrentAccount } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
@@ -31,11 +32,39 @@
const query = createQuery() const query = createQuery()
let slots: WorkSlot[] = [] let slots: WorkSlot[] = []
let reminders: number[] = []
let remindersHydrated = false
let remindersKey = ''
let currentTodoId: Ref<ToDo> | undefined
$: if (todo?._id !== undefined && currentTodoId !== todo._id) {
currentTodoId = todo._id
remindersHydrated = false
reminders = []
remindersKey = ''
}
$: query.query(time.class.WorkSlot, { attachedTo: todo._id }, (res) => { $: query.query(time.class.WorkSlot, { attachedTo: todo._id }, (res) => {
slots = res slots = res
if (!remindersHydrated) {
reminders = [...(slots[0]?.reminders ?? [])]
remindersHydrated = true
remindersKey = JSON.stringify(reminders)
}
}) })
$: {
if (!remindersHydrated || slots.length === 0) {
// no-op
} else {
const nextKey = JSON.stringify(reminders)
if (nextKey !== remindersKey) {
remindersKey = nextKey
void syncRemindersForSlots()
}
}
}
async function change (e: CustomEvent<{ startDate: number, dueDate: number, slot: Ref<WorkSlot> }>): Promise<void> { async function change (e: CustomEvent<{ startDate: number, dueDate: number, slot: Ref<WorkSlot> }>): Promise<void> {
const { startDate, dueDate, slot } = e.detail const { startDate, dueDate, slot } = e.detail
const workslot = slots.find((s) => s._id === slot) const workslot = slots.find((s) => s._id === slot)
@@ -72,14 +101,22 @@
access: AccessLevel.Owner, access: AccessLevel.Owner,
user: currentAccount.primarySocialId, user: currentAccount.primarySocialId,
visibility: todo.visibility === 'public' ? 'public' : 'freeBusy', visibility: todo.visibility === 'public' ? 'public' : 'freeBusy',
reminders: [] reminders
}) })
Analytics.handleEvent(TimeEvents.ToDoScheduled, { id: todo._id }) Analytics.handleEvent(TimeEvents.ToDoScheduled, { id: todo._id })
} }
async function syncRemindersForSlots (): Promise<void> {
await Promise.all(
slots.map(async (slot) => {
await client.update(slot, { reminders })
})
)
}
async function remove (e: CustomEvent<{ _id: Ref<WorkSlot> }>): Promise<void> { async function remove (e: CustomEvent<{ _id: Ref<WorkSlot> }>): Promise<void> {
const object = slots.find((p) => p._id === e.detail._id) const object = slots.find((p) => p._id === e.detail._id)
if (object) { if (object !== undefined) {
showPopup( showPopup(
contact.component.DeleteConfirmationPopup, contact.component.DeleteConfirmationPopup,
{ {
@@ -98,4 +135,11 @@
} }
</script> </script>
<Workslots {slots} fixed={'toDo'} on:change={change} on:dueChange={dueChange} on:create={create} on:remove={remove} /> <div class="flex-col">
<Workslots {slots} fixed={'toDo'} on:change={change} on:dueChange={dueChange} on:create={create} on:remove={remove} />
{#if slots.length > 0}
<div class="flex pt-4">
<EventReminders bind:reminders />
</div>
{/if}
</div>
+21 -10
View File
@@ -37,7 +37,7 @@ import { promisify } from 'util'
import { gzip } from 'zlib' import { gzip } from 'zlib'
import { retrieveJson } from './utils' import { retrieveJson } from './utils'
import { unknownError } from '@hcengineering/platform' import platform, { PlatformError, unknownError } from '@hcengineering/platform'
export const COMMUNICATION_DOMAIN = 'communication' as OperationDomain export const COMMUNICATION_DOMAIN = 'communication' as OperationDomain
interface RPCClientInfo { interface RPCClientInfo {
@@ -270,15 +270,26 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
void withSession(req, res, 'tx', async (ctx, session, rateLimit) => { void withSession(req, res, 'tx', async (ctx, session, rateLimit) => {
const tx: any = (await retrieveJson(req)) ?? {} const tx: any = (await retrieveJson(req)) ?? {}
if (tx._class === core.class.TxDomainEvent) { try {
const domainTx = tx as TxDomainEvent if (tx._class === core.class.TxDomainEvent) {
const { result } = await session.domainRequestRaw(ctx, domainTx.domain, { const domainTx = tx as TxDomainEvent
event: domainTx.event const { result } = await session.domainRequestRaw(ctx, domainTx.domain, {
}) event: domainTx.event
await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit)) })
} else { await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit))
const result = await session.txRaw(ctx, tx) } else {
await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit)) const result = await session.txRaw(ctx, tx)
await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit))
}
} catch (err: unknown) {
if (err instanceof PlatformError && err.status.code === platform.status.BadRequest) {
sendError(res, 400, {
message: 'Invalid tx',
error: err.status
})
return
}
throw err
} }
}) })
}) })
+5
View File
@@ -2221,6 +2221,11 @@
"projectFolder": "services/notification/pod-notification", "projectFolder": "services/notification/pod-notification",
"shouldPublish": false "shouldPublish": false
}, },
{
"packageName": "@hcengineering/pod-events-processor",
"projectFolder": "services/notification/pod-events-processor",
"shouldPublish": false
},
{ {
"packageName": "@hcengineering/pod-telegram", "packageName": "@hcengineering/pod-telegram",
"projectFolder": "services/telegram/pod-telegram", "projectFolder": "services/telegram/pod-telegram",
@@ -44,6 +44,128 @@ import { QueueTopic, TriggerControl } from '@hcengineering/server-core'
import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources' import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources'
import { generateToken } from '@hcengineering/server-token' 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 * @public
*/ */
@@ -214,6 +336,10 @@ async function onEventUpdate (ctx: TxUpdateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'update', control) void sendEventToService(event, 'update', control)
} }
void putEventToQueue(control, 'update', event, ctx.modifiedBy, ops) 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 [] if (event.access !== 'owner') return []
const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId }) const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId })
const res: Tx[] = [] const res: Tx[] = []
@@ -363,6 +489,8 @@ async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'create', control) void sendEventToService(event, 'create', control)
} }
void putEventToQueue(control, 'create', event, ctx.modifiedBy) 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 [] if (event.access !== 'owner') return []
const res: Tx[] = [] const res: Tx[] = []
const { _class, space, ...attr } = event 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[]> { async function onRemoveEvent (ctx: TxRemoveDoc<Event>, control: TriggerControl): Promise<Tx[]> {
const removed = control.removedMap.get(ctx.objectId) as Event const removed = control.removedMap.get(ctx.objectId) as Event
const res: Tx[] = [] const res: Tx[] = []
void cancelEventReminders(control, ctx.objectId)
if (removed !== undefined) { if (removed !== undefined) {
if (ctx.modifiedBy !== core.account.System && removed.access === 'owner') { if (ctx.modifiedBy !== core.account.System && removed.access === 'owner') {
void sendEventToService(removed, 'delete', control) 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 serverCore, { TriggerControl } from '@hcengineering/server-core'
import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification' import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification'
import type { ReceiverInfo } from '@hcengineering/server-notification'
import { import {
AccountUuid, AccountUuid,
Class, Class,
@@ -49,6 +50,7 @@ import contact, {
} from '@hcengineering/contact' } from '@hcengineering/contact'
import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index' import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index'
import { getPerson } from '@hcengineering/server-contact' import { getPerson } from '@hcengineering/server-contact'
import { getAllowedProviders, getNotificationProviderControl, getReceiversInfo } from './utils'
async function createPushFromInbox ( async function createPushFromInbox (
control: TriggerControl, control: TriggerControl,
@@ -235,25 +237,51 @@ export async function PushNotificationsHandler (
): Promise<Tx[]> { ): Promise<Tx[]> {
const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map() const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map()
const all: InboxNotification[] = txes const all: InboxNotification[] = txes.map((tx) => TxProcessor.createDoc2Doc(tx))
.map((tx) => TxProcessor.createDoc2Doc(tx))
.filter(
(it) =>
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
)
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 [] 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) => const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) =>
receivers.has(it.user) receivers.has(it.user)
) )
const res: Tx[] = [] const res: Tx[] = []
for (const inboxNotification of all) { for (const inboxNotification of pushEnabled) {
const { user } = inboxNotification const { user } = inboxNotification
const userSubscriptions = subscriptions.filter((it) => it.user === user) const userSubscriptions = subscriptions.filter((it) => it.user === user)
@@ -32,6 +32,7 @@ describe('AttachmentHandler', () => {
info: jest.fn(), info: jest.fn(),
error: jest.fn(), error: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
logOperation: jest.fn(), logOperation: jest.fn(),
childLogger: jest.fn(), childLogger: jest.fn(),
close: jest.fn() close: jest.fn()
@@ -40,6 +41,7 @@ describe('AttachmentHandler', () => {
error: jest.fn(), error: jest.fn(),
info: jest.fn(), info: jest.fn(),
warn: jest.fn(), warn: jest.fn(),
debug: jest.fn(),
end: jest.fn(), end: jest.fn(),
getParams: jest.fn() getParams: jest.fn()
} }
@@ -0,0 +1,8 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
@@ -0,0 +1,6 @@
FROM hardcoreeng/base-slim:v20250916
WORKDIR /usr/src/app
COPY bundle/bundle.js ./
CMD [ "node", "bundle.js" ]
@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ['./src'],
coverageReporters: ['text-summary', 'html']
}
@@ -0,0 +1,71 @@
{
"name": "@hcengineering/pod-events-processor",
"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/events-processor",
"docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/events-processor staging",
"docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/events-processor",
"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/calendar": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/core": "workspace:^0.7.26",
"@hcengineering/kafka": "workspace:^0.7.18",
"@hcengineering/notification": "workspace:^0.7.0",
"@hcengineering/platform": "workspace:^0.7.20",
"@hcengineering/server-client": "workspace:^0.7.16",
"@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"
}
}
@@ -0,0 +1,77 @@
//
// 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 { PersonId, WorkspaceUuid } from '@hcengineering/core'
import type { ClientBundle } from '../client'
import {
clearClientCachesForTests,
clearInFlightClientCreation,
getCacheKey,
getCachedClient,
getInFlightClientCreation,
setCachedClient,
setInFlightClientCreation
} from '../clientCache'
import config from '../config'
describe('clientCache', () => {
const workspace = 'workspace-1' as WorkspaceUuid
const serviceTag = 'events-processor'
const socialId = 'person-1' as PersonId
const client = {} as unknown as ClientBundle['client']
const accountClient = {} as unknown as ClientBundle['accountClient']
const bundle: ClientBundle = { client, accountClient }
beforeEach(() => {
clearClientCachesForTests()
})
afterEach(() => {
jest.restoreAllMocks()
})
it('uses system marker in cache key when socialId is undefined', () => {
expect(getCacheKey(workspace, undefined, serviceTag)).toBe('workspace-1:system:events-processor')
})
it('returns cached value before ttl expires', () => {
const now = 1_000
jest.spyOn(Date, 'now').mockReturnValue(now)
setCachedClient(workspace, socialId, serviceTag, bundle)
expect(getCachedClient(workspace, socialId, serviceTag)).toBe(bundle)
})
it('evicts cached value after ttl expires', () => {
const now = 1_000
const dateNow = jest.spyOn(Date, 'now').mockReturnValue(now)
setCachedClient(workspace, socialId, serviceTag, bundle)
dateNow.mockReturnValue(now + config.ClientCacheTtlMs + 1)
expect(getCachedClient(workspace, socialId, serviceTag)).toBeUndefined()
})
it('stores and clears in-flight client creation', () => {
const key = getCacheKey(workspace, socialId, serviceTag)
const creation = Promise.resolve(bundle)
setInFlightClientCreation(key, creation)
expect(getInFlightClientCreation(key)).toBe(creation)
clearInFlightClientCreation(key)
expect(getInFlightClientCreation(key)).toBeUndefined()
})
})
@@ -0,0 +1,579 @@
//
// 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 from '@hcengineering/contact'
import core, { type MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { PlatformError, Severity, Status } from '@hcengineering/platform'
import type { ConsumerControl } from '@hcengineering/server-core'
import time from '@hcengineering/time'
import { getClient } from '../client'
import type { ScheduledNotificationMessage } from '../types'
import { buildReminderNotificationId, handleScheduledNotification } from '../worker'
const eventClassRef = 'calendar:class:Event' as any
jest.mock('../client', () => ({
getClient: jest.fn()
}))
describe('handleScheduledNotification', () => {
const workspaceUuid = 'workspace-1' as WorkspaceUuid
const control = {
heartbeat: jest.fn(async () => {})
} as unknown as ConsumerControl
const ctx = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn()
} as unknown as MeasureContext
const findOne = jest.fn()
const createDoc = jest.fn()
const findPersonBySocialId = jest.fn()
const isDerived = jest.fn((child: any, parent: any) => {
if (child === parent) return true
if (parent === time.class.ToDo) {
// ToDo and its known subclasses.
return child === time.class.ToDo || child === 'time:class:ProjectToDo'
}
return false
})
const getHierarchy = jest.fn(() => ({ isDerived }))
const client = { findOne, createDoc, getHierarchy }
const accountClient = { findPersonBySocialId }
beforeEach(() => {
jest.clearAllMocks()
;(getClient as jest.Mock).mockResolvedValue({ client, accountClient })
})
// --------------------------------------------------------------------------------
// Common helpers
// --------------------------------------------------------------------------------
const workSlotMessage: ScheduledNotificationMessage = {
kind: 'eventReminder',
id: 'timer-workslot-1',
eventId: 'workslot-1' as any,
eventClass: time.class.WorkSlot as any,
shiftMs: 1000,
targetDate: 1_000_000
}
const eventMessage: ScheduledNotificationMessage = {
kind: 'eventReminder',
id: 'timer-event-1',
eventId: 'event-1' as any,
eventClass: eventClassRef,
shiftMs: 1000,
targetDate: 1_000_000
}
// --------------------------------------------------------------------------------
// Negative paths: things the worker should silently ignore.
// --------------------------------------------------------------------------------
it('returns early for non-eventReminder messages (e.g. legacy todoReminder messages)', async () => {
const legacy = { ...workSlotMessage, kind: 'todoReminder' as any }
await handleScheduledNotification(ctx, workspaceUuid, legacy as any, control)
expect(getClient).not.toHaveBeenCalled()
expect(control.heartbeat).not.toHaveBeenCalled()
})
// --------------------------------------------------------------------------------
// WorkSlot path: notification points at the parent ToDo (legacy UX preserved).
// --------------------------------------------------------------------------------
describe('WorkSlot/ToDo branch', () => {
const expectedNotificationId = buildReminderNotificationId(workSlotMessage.id)
it('skips when the ToDo is already done', async () => {
findOne.mockImplementation(async (klass: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return {
_id: 'todo-1',
_class: 'time:class:ToDo',
space: 'space-1',
user: 'employee-1',
title: 'Done todo',
doneOn: Date.now()
}
}
return undefined
})
await handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)
expect(createDoc).not.toHaveBeenCalled()
expect(ctx.info).not.toHaveBeenCalled()
})
it('creates DocNotifyContext + CommonInboxNotification pointing at the ToDo', async () => {
let docNotifyContextCreated = false
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return {
_id: 'todo-1',
_class: 'time:class:ToDo',
space: 'space-1',
user: 'employee-1',
title: 'Todo title',
doneOn: null
}
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification && query?._id === expectedNotificationId) {
return undefined
}
if (klass === notification.class.DocNotifyContext) {
return docNotifyContextCreated ? { _id: 'doc-notify-created-id' } : undefined
}
return undefined
})
createDoc.mockImplementation(async (klass: any) => {
if (klass === notification.class.DocNotifyContext) {
docNotifyContextCreated = true
return 'doc-notify-created-id'
}
return expectedNotificationId
})
await handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)
// 1st createDoc: DocNotifyContext targeting the ToDo, with System modifiedBy.
expect(createDoc).toHaveBeenNthCalledWith(
1,
notification.class.DocNotifyContext,
'person-space-1',
expect.objectContaining({
objectId: 'todo-1',
objectClass: 'time:class:ToDo',
objectSpace: 'space-1',
user: 'person-1'
}),
undefined,
undefined,
core.account.System
)
// 2nd createDoc: CommonInboxNotification pointing at the ToDo with deterministic _id.
expect(createDoc).toHaveBeenNthCalledWith(
2,
notification.class.CommonInboxNotification,
'person-space-1',
expect.objectContaining({
user: 'person-1',
objectId: 'todo-1',
objectClass: 'time:class:ToDo',
headerIcon: calendar.icon.Reminder,
types: [calendar.ids.ReminderNotification],
docNotifyContext: 'doc-notify-created-id'
}),
expectedNotificationId,
undefined,
core.account.System
)
// accountClient is NOT consulted on the ToDo path — it's the plain-event path that uses it.
expect(findPersonBySocialId).not.toHaveBeenCalled()
})
// Regression: tracker-issue WorkSlots have `attachedToClass = 'time:class:ProjectToDo'` (a
// ToDo subclass created by `IssueToDoFactory`). Strict equality `attachedToClass === ToDo`
// would silently take the plain-event branch and route the notification at the WorkSlot
// instead of the parent ProjectToDo.
it('treats a ProjectToDo-backed WorkSlot as ToDo-backed (subclass via isDerived)', async () => {
const projectTodoClass = 'time:class:ProjectToDo' as any
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'project-todo-1',
attachedToClass: projectTodoClass
}
}
if (klass === projectTodoClass) {
return {
_id: 'project-todo-1',
_class: projectTodoClass,
space: 'space-1',
user: 'employee-1',
title: 'Issue ToDo',
doneOn: null
}
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification) return undefined
if (klass === notification.class.DocNotifyContext) return undefined
return undefined
})
createDoc
.mockResolvedValueOnce('doc-notify-created-id')
.mockResolvedValueOnce(buildReminderNotificationId(workSlotMessage.id))
await handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)
// hierarchy.isDerived must have been consulted to recognize ProjectToDo as a ToDo.
expect(isDerived).toHaveBeenCalledWith(projectTodoClass, time.class.ToDo)
// The notification points at the ProjectToDo, not at the WorkSlot.
expect(createDoc.mock.calls[1][0]).toBe(notification.class.CommonInboxNotification)
expect(createDoc.mock.calls[1][2]).toEqual(
expect.objectContaining({
objectId: 'project-todo-1',
objectClass: projectTodoClass
})
)
// accountClient is NOT consulted — the ToDo-backed path doesn't need it.
expect(findPersonBySocialId).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------------
// Plain Event path: notification points at the Event itself.
// --------------------------------------------------------------------------------
describe('plain Event branch', () => {
const expectedNotificationId = buildReminderNotificationId(eventMessage.id)
it('creates DocNotifyContext + CommonInboxNotification pointing at the Event', async () => {
findPersonBySocialId.mockResolvedValue('person-uuid-1')
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === eventClassRef) {
return {
_id: eventMessage.eventId,
_class: eventClassRef,
space: 'event-space-1',
attachedTo: 'some-doc',
attachedToClass: 'some:class:Doc',
user: 'social:1',
title: 'Sprint planning meeting',
date: Date.now()
}
}
if (klass === contact.class.Person && query?.personUuid === 'person-uuid-1') {
return { _id: 'person-1-doc' }
}
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification && query?._id === expectedNotificationId) {
return undefined
}
if (klass === notification.class.DocNotifyContext) return undefined
return undefined
})
createDoc
.mockResolvedValueOnce('doc-notify-created-id') // DocNotifyContext
.mockResolvedValueOnce(expectedNotificationId) // CommonInboxNotification
await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control)
expect(findPersonBySocialId).toHaveBeenCalledWith('social:1', true)
// DocNotifyContext targets the Event itself.
expect(createDoc).toHaveBeenNthCalledWith(
1,
notification.class.DocNotifyContext,
'person-space-1',
expect.objectContaining({
objectId: eventMessage.eventId,
objectClass: eventClassRef,
objectSpace: 'event-space-1',
user: 'person-uuid-1'
}),
undefined,
undefined,
core.account.System
)
// Notification targets the Event itself, with the event's title in messageHtml.
expect(createDoc).toHaveBeenNthCalledWith(
2,
notification.class.CommonInboxNotification,
'person-space-1',
expect.objectContaining({
user: 'person-uuid-1',
objectId: eventMessage.eventId,
objectClass: eventClassRef
}),
expectedNotificationId,
undefined,
core.account.System
)
// The notification's title text should be derived from `event.title`, not a ToDo.
const notifData = createDoc.mock.calls[1][2]
expect(typeof notifData.messageHtml).toBe('string')
expect(notifData.messageHtml).toContain('Sprint planning meeting')
})
it('skips when the social id does not resolve to a global person', async () => {
findPersonBySocialId.mockResolvedValue(undefined)
findOne.mockImplementation(async (klass: any) => {
if (klass === eventClassRef) {
return {
_id: eventMessage.eventId,
_class: eventClassRef,
space: 'event-space-1',
attachedTo: 'some-doc',
attachedToClass: 'some:class:Doc',
user: 'social:1',
title: 'Mystery'
}
}
return undefined
})
await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control)
expect(createDoc).not.toHaveBeenCalled()
})
it('skips when the event has no `user` (social id) — silently, no error', async () => {
findOne.mockImplementation(async (klass: any) => {
if (klass === eventClassRef) {
return {
_id: eventMessage.eventId,
_class: eventClassRef,
space: 'event-space-1',
attachedTo: 'some-doc',
attachedToClass: 'some:class:Doc',
user: undefined,
title: 'Untitled'
}
}
return undefined
})
await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control)
expect(findPersonBySocialId).not.toHaveBeenCalled()
expect(createDoc).not.toHaveBeenCalled()
expect(ctx.error).not.toHaveBeenCalled()
})
it('skips when the receiver has no PersonSpace', async () => {
findPersonBySocialId.mockResolvedValue('person-uuid-1')
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === eventClassRef) {
return {
_id: eventMessage.eventId,
_class: eventClassRef,
space: 'event-space-1',
attachedTo: 'some-doc',
attachedToClass: 'some:class:Doc',
user: 'social:1',
title: 't'
}
}
if (klass === contact.class.Person && query?.personUuid === 'person-uuid-1') return { _id: 'person-1-doc' }
return undefined
})
await handleScheduledNotification(ctx, workspaceUuid, eventMessage, control)
expect(createDoc).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------------
// Idempotency + error logging are independent of which branch we took.
// --------------------------------------------------------------------------------
describe('idempotency and error logging', () => {
const expectedNotificationId = buildReminderNotificationId(workSlotMessage.id)
it('skips creation when reminder was already created (idempotency by _id)', async () => {
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return {
_id: 'todo-1',
_class: 'time:class:ToDo',
space: 'space-1',
user: 'employee-1',
title: 'Todo title'
}
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification && query?._id === expectedNotificationId) {
return { _id: expectedNotificationId }
}
return undefined
})
await handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)
expect(createDoc).not.toHaveBeenCalled()
expect(ctx.info).not.toHaveBeenCalled()
})
it('reuses an existing DocNotifyContext without creating a new one', async () => {
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return { _id: 'todo-1', _class: 'time:class:ToDo', space: 'space-1', user: 'employee-1', title: 't' }
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification && query?._id === expectedNotificationId) {
return undefined
}
if (klass === notification.class.DocNotifyContext) return { _id: 'existing-doc-notify' }
return undefined
})
createDoc.mockResolvedValue(expectedNotificationId)
await handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)
expect(createDoc).toHaveBeenCalledTimes(1)
expect(createDoc).toHaveBeenCalledWith(
notification.class.CommonInboxNotification,
'person-space-1',
expect.objectContaining({ docNotifyContext: 'existing-doc-notify' }),
expectedNotificationId,
undefined,
core.account.System
)
})
it('logs a detailed error and rethrows when CommonInboxNotification createDoc fails with Bad Request', async () => {
findOne.mockImplementation(async (klass: any, query: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return { _id: 'todo-1', _class: 'time:class:ToDo', space: 'space-1', user: 'employee-1', title: 't' }
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification && query?._id === expectedNotificationId) {
return undefined
}
if (klass === notification.class.DocNotifyContext) return { _id: 'existing-doc-notify' }
return undefined
})
const badRequest = new PlatformError(
new Status(Severity.ERROR, 'platform:status:UnknownError' as any, { message: 'Bad Request' })
)
createDoc.mockRejectedValueOnce(badRequest)
await expect(handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)).rejects.toBe(badRequest)
expect(ctx.error).toHaveBeenCalledWith(
'Failed to create CommonInboxNotification for event reminder',
expect.objectContaining({
err: badRequest,
timerId: workSlotMessage.id,
eventId: workSlotMessage.eventId,
eventClass: workSlotMessage.eventClass,
notificationId: expectedNotificationId,
spaceId: 'person-space-1',
user: 'person-1'
})
)
})
it('logs a detailed error and rethrows when DocNotifyContext createDoc fails', async () => {
findOne.mockImplementation(async (klass: any) => {
if (klass === time.class.WorkSlot) {
return {
_id: workSlotMessage.eventId,
_class: time.class.WorkSlot,
space: 'space-1',
attachedTo: 'todo-1',
attachedToClass: time.class.ToDo
}
}
if (klass === time.class.ToDo) {
return { _id: 'todo-1', _class: 'time:class:ToDo', space: 'space-1', user: 'employee-1', title: 't' }
}
if (klass === contact.mixin.Employee) return { personUuid: 'person-1' }
if (klass === contact.class.PersonSpace) return { _id: 'person-space-1' }
if (klass === notification.class.CommonInboxNotification) return undefined
if (klass === notification.class.DocNotifyContext) return undefined
return undefined
})
const badRequest = new PlatformError(
new Status(Severity.ERROR, 'platform:status:UnknownError' as any, { message: 'Bad Request' })
)
createDoc.mockRejectedValueOnce(badRequest)
await expect(handleScheduledNotification(ctx, workspaceUuid, workSlotMessage, control)).rejects.toBe(badRequest)
expect(ctx.error).toHaveBeenCalledWith(
'Failed to create DocNotifyContext for event reminder',
expect.objectContaining({
err: badRequest,
timerId: workSlotMessage.id,
eventId: workSlotMessage.eventId,
eventClass: workSlotMessage.eventClass,
spaceId: 'person-space-1',
user: 'person-1'
})
)
expect(createDoc).toHaveBeenCalledTimes(1)
})
it('builds a stable, distinct notification _id per timer', () => {
const a = buildReminderNotificationId('timer-A')
const b = buildReminderNotificationId('timer-B')
expect(a).not.toBe(b)
expect(buildReminderNotificationId('timer-A')).toBe(a)
expect(a.startsWith('eventReminderInbox:')).toBe(true)
})
})
})
@@ -0,0 +1,78 @@
//
// 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 {
clearInFlightClientCreation,
getCacheKey,
getCachedClient,
getInFlightClientCreation,
setCachedClient,
setInFlightClientCreation
} from './clientCache'
import config from './config'
export interface ClientBundle {
client: TxOperations
accountClient: AccountClient
}
export async function getClient (
workspaceUuid: WorkspaceUuid,
socialId?: PersonId,
serviceTag: string = config.ServiceId
): Promise<ClientBundle> {
const cached = getCachedClient(workspaceUuid, socialId, serviceTag)
if (cached !== undefined) return cached
const cacheKey = getCacheKey(workspaceUuid, socialId, serviceTag)
const inFlight = getInFlightClientCreation(cacheKey)
if (inFlight !== undefined) return await inFlight
const creation = (async () => {
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)
const bundle = { client, accountClient }
setCachedClient(workspaceUuid, socialId, serviceTag, bundle)
return bundle
})()
setInFlightClientCreation(cacheKey, creation)
try {
return await creation
} finally {
clearInFlightClientCreation(cacheKey)
}
}
@@ -0,0 +1,72 @@
//
// 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 PersonId, type WorkspaceUuid } from '@hcengineering/core'
import type { ClientBundle } from './client'
import config from './config'
interface CacheEntry {
value: ClientBundle
expiresAt: number
}
const clientCache = new Map<string, CacheEntry>()
const inFlightClientCreations = new Map<string, Promise<ClientBundle>>()
export function getCacheKey (workspaceUuid: WorkspaceUuid, socialId: PersonId | undefined, serviceTag: string): string {
return `${workspaceUuid}:${socialId ?? 'system'}:${serviceTag}`
}
export function getCachedClient (
workspaceUuid: WorkspaceUuid,
socialId: PersonId | undefined,
serviceTag: string
): ClientBundle | undefined {
const key = getCacheKey(workspaceUuid, socialId, serviceTag)
const entry = clientCache.get(key)
if (entry === undefined) return undefined
if (entry.expiresAt <= Date.now()) {
clientCache.delete(key)
return undefined
}
return entry.value
}
export function setCachedClient (
workspaceUuid: WorkspaceUuid,
socialId: PersonId | undefined,
serviceTag: string,
value: ClientBundle
): void {
const key = getCacheKey(workspaceUuid, socialId, serviceTag)
clientCache.set(key, { value, expiresAt: Date.now() + config.ClientCacheTtlMs })
}
export function getInFlightClientCreation (key: string): Promise<ClientBundle> | undefined {
return inFlightClientCreations.get(key)
}
export function setInFlightClientCreation (key: string, creation: Promise<ClientBundle>): void {
inFlightClientCreations.set(key, creation)
}
export function clearInFlightClientCreation (key: string): void {
inFlightClientCreations.delete(key)
}
export function clearClientCachesForTests (): void {
clientCache.clear()
inFlightClientCreations.clear()
}
@@ -0,0 +1,38 @@
//
// 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
LogLevel: 'info' | 'debug'
ClientCacheTtlMs: number
}
const config: Config = {
ServiceId: process.env.SERVICE_ID ?? 'events-processor',
Secret: process.env.SECRET ?? 'secret',
AccountsUrl: process.env.ACCOUNTS_URL ?? 'http://localhost:3000',
QueueRegion: process.env.QUEUE_REGION ?? 'localhost',
LogLevel: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',
ClientCacheTtlMs: Number(process.env.CLIENT_CACHE_TTL_MS ?? 60_000)
}
export default config
@@ -0,0 +1,98 @@
//
// 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, type ConsumerControl } 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'
const isDebugLoggingEnabled = config.LogLevel === 'debug'
const serviceVersion = process.env.VERSION ?? '0.7.0'
async function main (): Promise<void> {
configureAnalytics(config.ServiceId, serviceVersion)
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<ScheduledNotificationMessage>(
ctx,
scheduledNotificationTopic,
queue.getClientId(),
async (ctx, message, control: ConsumerControl) => {
if (isDebugLoggingEnabled) {
ctx.info('Received scheduled notification event', {
topic: scheduledNotificationTopic,
workspace: message.workspace,
kind: message.value?.kind,
id: message.value?.id
})
}
await handleScheduledNotification(ctx, message.workspace, message.value, control)
}
)
ctx.info(`Started events processor: version ${serviceVersion}`, {
serviceId: config.ServiceId,
version: serviceVersion,
topic: scheduledNotificationTopic,
queueRegion: config.QueueRegion,
logLevel: config.LogLevel
})
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)
})
@@ -0,0 +1,31 @@
//
// 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 { Class, Doc, Ref } from '@hcengineering/core'
/**
* Scheduled notification fired by the time-machine consumer.
*
* `kind === 'eventReminder'` covers any `calendar.Event` subclass — including `WorkSlot`,
* which is handled with a small ToDo-specific branch in the worker.
*/
export interface ScheduledNotificationMessage {
kind: 'eventReminder'
id: string
eventId: Ref<Doc>
eventClass: Ref<Class<Doc>>
shiftMs: number
targetDate: number
}
@@ -0,0 +1,233 @@
//
// 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
}
}
@@ -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"]
}
+2 -1
View File
@@ -33,7 +33,8 @@ When a timer expires, the service relays the exact `data` payload to the target
| `DB_URL` | `postgres://localhost:5432/huly` | Connection string for the PostgreSQL database. | | `DB_URL` | `postgres://localhost:5432/huly` | Connection string for the PostgreSQL database. |
| `POLL_INTERVAL` | `5000` | Polling interval for expired events in milliseconds. | | `POLL_INTERVAL` | `5000` | Polling interval for expired events in milliseconds. |
| `QUEUE_CONFIG` | - | Kafka bootstrap servers configuration. | | `QUEUE_CONFIG` | - | Kafka bootstrap servers configuration. |
| `QUEUE_REGION` | `cockroach` | Platform region configuration. | | `QUEUE_REGION` | (empty) | Kafka topic prefix; must match transactor `REGION` / other services `QUEUE_REGION`. |
| `LOG_LEVEL` | `info` | Set to `debug` for verbose diagnostic logs (`ctx.debug`) on TimeMachine consumes and poll batches. |
## Database Schema ## Database Schema
+3 -1
View File
@@ -21,13 +21,15 @@ export interface Config {
PollInterval: number PollInterval: number
QueueRegion: string QueueRegion: string
QueueConfig: string QueueConfig: string
LogLevel: 'info' | 'debug'
} }
const config: Config = { const config: Config = {
DbUrl: process.env.DB_URL ?? 'postgres://localhost:5432/huly', DbUrl: process.env.DB_URL ?? 'postgres://localhost:5432/huly',
PollInterval: process.env.POLL_INTERVAL != null ? Number(process.env.POLL_INTERVAL) : 20000, PollInterval: process.env.POLL_INTERVAL != null ? Number(process.env.POLL_INTERVAL) : 20000,
QueueRegion: process.env.QUEUE_REGION ?? '', QueueRegion: process.env.QUEUE_REGION ?? '',
QueueConfig: process.env.QUEUE_CONFIG ?? '' QueueConfig: process.env.QUEUE_CONFIG ?? '',
LogLevel: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'
} }
export default config export default config
+31 -3
View File
@@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import { MeasureMetricsContext } from '@hcengineering/core' import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
import { getPlatformQueue } from '@hcengineering/kafka' import { getPlatformQueue } from '@hcengineering/kafka'
import { QueueTopic } from '@hcengineering/server-core' import { QueueTopic } from '@hcengineering/server-core'
import { TimeMachineMessage } from '@hcengineering/server-process' import { TimeMachineMessage } from '@hcengineering/server-process'
@@ -25,13 +25,29 @@ export async function runWorker (): Promise<void> {
const SERVICE_NAME = 'time-machine' const SERVICE_NAME = 'time-machine'
const db = await TimeMachineDB.init(config.DbUrl) const db = await TimeMachineDB.init(config.DbUrl)
const ctx = new MeasureMetricsContext(SERVICE_NAME, {}) const ctx = new MeasureMetricsContext(
SERVICE_NAME,
{},
{},
newMetrics(),
undefined,
undefined,
undefined,
config.LogLevel
)
const queue = getPlatformQueue(SERVICE_NAME, config.QueueRegion) const queue = getPlatformQueue(SERVICE_NAME, config.QueueRegion)
// 1. Kafka Consumer for commands // 1. Kafka Consumer for commands
queue.createConsumer<TimeMachineMessage>(ctx, QueueTopic.TimeMachine, SERVICE_NAME, async (ctx, msg) => { queue.createConsumer<TimeMachineMessage>(ctx, QueueTopic.TimeMachine, SERVICE_NAME, async (ctx, msg) => {
const { type, id, targetDate, topic, data } = msg.value const { type, id, targetDate, topic, data } = msg.value
if (type === 'schedule' && targetDate != null && topic != null && data !== undefined) { if (type === 'schedule' && targetDate != null && topic != null && data !== undefined) {
ctx.debug('TimeMachine consume schedule', {
workspace: msg.workspace,
id,
targetDate,
targetDateIso: new Date(targetDate).toISOString(),
outTopic: topic
})
await db.upsertEvent({ await db.upsertEvent({
id, id,
workspace: msg.workspace, workspace: msg.workspace,
@@ -40,6 +56,7 @@ export async function runWorker (): Promise<void> {
data data
}) })
} else if (type === 'cancel') { } else if (type === 'cancel') {
ctx.debug('TimeMachine consume cancel', { workspace: msg.workspace, idPattern: id })
await db.removeEvents(msg.workspace, id) await db.removeEvents(msg.workspace, id)
} }
}) })
@@ -49,13 +66,24 @@ export async function runWorker (): Promise<void> {
try { try {
const expiredEvents = await db.getExpiredEvents() const expiredEvents = await db.getExpiredEvents()
if (expiredEvents.length > 0) { if (expiredEvents.length > 0) {
ctx.debug('TimeMachine poll expired', {
count: expiredEvents.length,
ids: expiredEvents.map((e) => e.id)
})
for (const event of expiredEvents) { for (const event of expiredEvents) {
ctx.debug('TimeMachine relay', {
workspace: event.workspace,
id: event.id,
outTopic: event.topic,
targetDate: event.target_date,
targetDateIso: new Date(event.target_date).toISOString()
})
await SendTimeEvent(ctx, event.workspace, event.topic, event.data) await SendTimeEvent(ctx, event.workspace, event.topic, event.data)
} }
await db.deleteEvents(expiredEvents) await db.deleteEvents(expiredEvents)
} }
} catch (err) { } catch (err) {
ctx.error('Error in Time Machine polling loop:') ctx.error('Error in Time Machine polling loop', { err })
} finally { } finally {
setTimeout(() => { setTimeout(() => {
void poll() void poll()