From 3353c531bd9982833427b3ec3e5247479e8add32 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Sun, 5 Jul 2026 08:01:30 +0700 Subject: [PATCH 01/15] Fix accounts restore error (#10955) Signed-off-by: Artyom Savchenko --- .../src/__tests__/restoreSocialIds.spec.ts | 100 +++++++++++++++++ server/backup/src/restore.ts | 102 +++++++++++++----- 2 files changed, 174 insertions(+), 28 deletions(-) create mode 100644 server/backup/src/__tests__/restoreSocialIds.spec.ts diff --git a/server/backup/src/__tests__/restoreSocialIds.spec.ts b/server/backup/src/__tests__/restoreSocialIds.spec.ts new file mode 100644 index 0000000000..fb1c37b30d --- /dev/null +++ b/server/backup/src/__tests__/restoreSocialIds.spec.ts @@ -0,0 +1,100 @@ +// +// 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 { AccountDB, SocialId } from '@hcengineering/account' +import { MeasureMetricsContext } from '@hcengineering/core' + +import { restoreSocialIds } from '../restore' + +interface MockDb { + accountDb: AccountDB + inserted: any[] +} + +function createMockAccountDb (existingPersonUuids: string[], existingSocialIdKeys: string[] = []): MockDb { + const inserted: any[] = [] + const accountDb = { + person: { + find: jest.fn(async (query: any) => + (query.uuid.$in as string[]).filter((uuid) => existingPersonUuids.includes(uuid)).map((uuid) => ({ uuid })) + ) + }, + socialId: { + find: jest.fn(async (query: any) => + (query.key.$in as string[]).filter((key) => existingSocialIdKeys.includes(key)).map((key) => ({ key })) + ), + insertMany: jest.fn(async (docs: any[]) => { + inserted.push(...docs) + return docs.map((it) => it._id) + }) + } + } as unknown as AccountDB + return { accountDb, inserted } +} + +function socialId (id: string, key: string, personUuid: string): SocialId { + return { + _id: id, + key, + personUuid, + type: 'email', + value: key + } as unknown as SocialId +} + +const ctx = new MeasureMetricsContext('test', {}) + +describe('restoreSocialIds', () => { + it('skips social ids referencing missing persons and inserts the rest', async () => { + const { accountDb, inserted } = createMockAccountDb(['person-1']) + + await restoreSocialIds(ctx, accountDb, [ + socialId('sid-1', 'email:a@b.c', 'person-1'), + socialId('sid-2', 'email:orphan@b.c', 'person-missing') + ]) + + expect(inserted).toHaveLength(1) + expect(inserted[0]._id).toBe('sid-1') + }) + + it('does not insert social ids which already exist', async () => { + const { accountDb, inserted } = createMockAccountDb(['person-1'], ['email:a@b.c']) + + await restoreSocialIds(ctx, accountDb, [socialId('sid-1', 'email:a@b.c', 'person-1')]) + + expect(inserted).toHaveLength(0) + expect((accountDb.socialId.insertMany as jest.Mock).mock.calls).toHaveLength(0) + }) + + it('does not call insertMany when all social ids are orphaned', async () => { + const { accountDb, inserted } = createMockAccountDb([]) + + await restoreSocialIds(ctx, accountDb, [socialId('sid-1', 'email:a@b.c', 'person-missing')]) + + expect(inserted).toHaveLength(0) + expect((accountDb.socialId.insertMany as jest.Mock).mock.calls).toHaveLength(0) + }) + + it('strips key and %hash% from inserted records', async () => { + const { accountDb, inserted } = createMockAccountDb(['person-1']) + const sid = { ...socialId('sid-1', 'email:a@b.c', 'person-1'), '%hash%': 'abc' } + + await restoreSocialIds(ctx, accountDb, [sid]) + + expect(inserted).toHaveLength(1) + expect(inserted[0].key).toBeUndefined() + expect(inserted[0]['%hash%']).toBeUndefined() + }) +}) diff --git a/server/backup/src/restore.ts b/server/backup/src/restore.ts index 8454182c3e..7a8d96f67d 100644 --- a/server/backup/src/restore.ts +++ b/server/backup/src/restore.ts @@ -578,40 +578,60 @@ export async function restore ( const limiter = new RateLimiter(opt.parallel ?? 1) + const isSkipped = (c: Domain): boolean => + (opt.include !== undefined && !opt.include.has(c)) || opt.skip?.has(c) === true + + async function processDomainWithRetry (c: Domain): Promise { + ctx.info('processing domain', { domain: c, workspaceId }) + let retry = 5 + let delay = 1 + while (retry > 0) { + retry-- + try { + const doProcessDomain = isAccountDomain(c) ? processAccountDomain : processDomain + await doProcessDomain(c) + if (delay > 1) { + ctx.warn('retry-success', { retry, delay, workspaceId }) + } + break + } catch (err: any) { + ctx.error('failed to process domain', { err, domain: c, workspaceId }) + if (retry !== 0) { + ctx.warn('cool-down to retry', { delay, domain: c, workspaceId }) + await new Promise((resolve) => setTimeout(resolve, delay * 1000)) + delay++ + } + } + } + } + try { let i = 0 - for (const c of domains) { + // Account domains are restored sequentially and in a fixed order: + // persons first, then social ids, since social_id.person_uuid references person. + const orderedAccountDomains = [toAccountDomain('person'), toAccountDomain('socialId')] + const accountDomainsToProcess = [ + ...orderedAccountDomains.filter((c) => domains.has(c)), + ...Array.from(domains).filter((c) => isAccountDomain(c) && !orderedAccountDomains.includes(c)) + ].filter((c) => !isSkipped(c)) + for (const c of accountDomainsToProcess) { if (opt.progress !== undefined) { await opt.progress?.(domainProgress) } - if (opt.include !== undefined && !opt.include.has(c)) { + await processDomainWithRetry(c) + domainProgress = Math.round(i / domains.size) * 100 + i++ + } + + for (const c of domains) { + if (isAccountDomain(c) || isSkipped(c)) { continue } - if (opt.skip?.has(c) === true) { - continue + if (opt.progress !== undefined) { + await opt.progress?.(domainProgress) } await limiter.add(async () => { - ctx.info('processing domain', { domain: c, workspaceId }) - let retry = 5 - let delay = 1 - while (retry > 0) { - retry-- - try { - const doProcessDomain = isAccountDomain(c) ? processAccountDomain : processDomain - await doProcessDomain(c) - if (delay > 1) { - ctx.warn('retry-success', { retry, delay, workspaceId }) - } - break - } catch (err: any) { - ctx.error('failed to process domain', { err, domain: c, workspaceId }) - if (retry !== 0) { - ctx.warn('cool-down to retry', { delay, domain: c, workspaceId }) - await new Promise((resolve) => setTimeout(resolve, delay * 1000)) - delay++ - } - } - } + await processDomainWithRetry(c) domainProgress = Math.round(i / domains.size) * 100 i++ }) @@ -686,7 +706,11 @@ async function restorePersons ( } } -async function restoreSocialIds (ctx: MeasureContext, accountDb: AccountDB, socialIds: SocialId[]): Promise { +export async function restoreSocialIds ( + ctx: MeasureContext, + accountDb: AccountDB, + socialIds: SocialId[] +): Promise { const chunks = chunkArray(socialIds, accountBatchSize) for (const chunk of chunks) { const ids = chunk.map((s) => s.key) @@ -695,9 +719,31 @@ async function restoreSocialIds (ctx: MeasureContext, accountDb: AccountDB, soci const existingSocialIds = await accountDb.socialId.find({ key: { $in: ids } }) const existingIds = new Set(existingSocialIds.map((s) => s.key)) + const missing = chunk.filter((s) => !existingIds.has(s.key)) + if (missing.length === 0) { + continue + } + + // social_id.person_uuid references person (social_id_person_fk). + // A backup may contain social ids whose person is not present in it + // (e.g. the workspace had a SocialIdentity without a matching contact + // Person with personUuid). Inserting such records would fail the whole + // batch, so filter them out and restore the rest. + const personUuids = Array.from(new Set(missing.map((s) => s.personUuid))) + const existingPersons = await accountDb.person.find({ uuid: { $in: personUuids } }) + const existingPersonUuids = new Set(existingPersons.map((p) => p.uuid)) + + const orphaned = missing.filter((s) => !existingPersonUuids.has(s.personUuid)) + if (orphaned.length > 0) { + ctx.warn('skipping social ids with missing persons', { + count: orphaned.length, + socialIds: orphaned.map((s) => ({ _id: s._id, key: s.key, personUuid: s.personUuid })) + }) + } + // Insert missing socialIds - const socialIdsToInsert: SocialId[] = chunk - .filter((s) => !existingIds.has(s.key)) + const socialIdsToInsert: SocialId[] = missing + .filter((s) => existingPersonUuids.has(s.personUuid)) .map((it) => { const { '%hash%': _1, key: _2, ...data } = it as any return data From 7f13ca3a0eeaff094cb23b1664f2f03263037a50 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Sun, 5 Jul 2026 21:48:13 +0500 Subject: [PATCH 02/15] Card space type filter (#10956) Signed-off-by: Denis Bykhov --- .../src/components/ChangeType.svelte | 10 +- .../src/components/CreateCardPopupFull.svelte | 95 +++++++++++++------ .../components/CreateCardPopupSimple.svelte | 58 ++++++++--- .../src/components/TypeSelector.svelte | 59 ++++++++---- 4 files changed, 153 insertions(+), 69 deletions(-) diff --git a/plugins/card-resources/src/components/ChangeType.svelte b/plugins/card-resources/src/components/ChangeType.svelte index 9ba96daef1..116bc8b5fc 100644 --- a/plugins/card-resources/src/components/ChangeType.svelte +++ b/plugins/card-resources/src/components/ChangeType.svelte @@ -28,12 +28,12 @@ const client = getClient() const hierarchy = client.getHierarchy() - let selected: Ref = value._class + let selected: Ref | null = value._class $: mapping = buildMapping(selected, value._class) async function changeType (): Promise { - if (selected === undefined || selected === value._class) return + if (selected == null || selected === value._class) return const cloned = hierarchy.clone(value) applyMapping(cloned, mapping) const update = fillDefaults(hierarchy, cloned, selected) @@ -54,8 +54,8 @@ } } - function buildMapping (selected: Ref | undefined, current: Ref): Record { - if (selected === undefined || selected === current) return {} + function buildMapping (selected: Ref | null, current: Ref): Record { + if (selected == null || selected === current) return {} const selectedAttributes = hierarchy.getAllAttributes(selected, card.class.Card) const currentAttributes = hierarchy.getAllAttributes(current, card.class.Card) const res: Record = {} @@ -81,7 +81,7 @@ label={card.string.ChangeType} okLabel={ui.string.Ok} okAction={changeType} - canSave={selected !== undefined && selected !== value._class} + canSave={selected != null && selected !== value._class} gap={'gapV-4'} on:close={() => { dispatch('close') diff --git a/plugins/card-resources/src/components/CreateCardPopupFull.svelte b/plugins/card-resources/src/components/CreateCardPopupFull.svelte index d770ccd63c..a70fd9ed26 100644 --- a/plugins/card-resources/src/components/CreateCardPopupFull.svelte +++ b/plugins/card-resources/src/components/CreateCardPopupFull.svelte @@ -12,8 +12,13 @@ - + {#if type != null} + + {/if}
{#if changeType}
- +
{/if} {#if (space == null || allowChangeSpace) && !(extension?.hideSpace ?? false)} diff --git a/plugins/card-resources/src/components/CreateCardPopupSimple.svelte b/plugins/card-resources/src/components/CreateCardPopupSimple.svelte index b5308d8a41..f5f4247340 100644 --- a/plugins/card-resources/src/components/CreateCardPopupSimple.svelte +++ b/plugins/card-resources/src/components/CreateCardPopupSimple.svelte @@ -12,8 +12,8 @@ - + diff --git a/plugins/card-resources/src/components/TypeSelector.svelte b/plugins/card-resources/src/components/TypeSelector.svelte index 44dd9d9e23..586efe801b 100644 --- a/plugins/card-resources/src/components/TypeSelector.svelte +++ b/plugins/card-resources/src/components/TypeSelector.svelte @@ -20,41 +20,53 @@ import { createEventDispatcher } from 'svelte' import card from '../plugin' import view from '@hcengineering/view' - import { getFirstCreatableSubtype, isBaseTypeWithSubtypes } from '../utils' + import { getFirstCreatableSubtype, getRootType, isBaseTypeWithSubtypes } from '../utils' - export let value: Ref + export let value: Ref | null export let width: string | undefined = undefined export let kind: ButtonKind | undefined = undefined export let size: ButtonSize | undefined = undefined export let parent: Ref = card.class.Card export let disabled: boolean = false export let excludeBaseTypes: boolean = false + export let allowedRootTypes: Ref[] | undefined = undefined const client = getClient() const hierarchy = client.getHierarchy() const dispatch = createEventDispatcher() - function filterClasses (): [DropdownIntlItem, DropdownIntlItem[]][] { - const descendants = hierarchy.getDescendants(parent).filter((p) => p !== parent) + function isAllowedBySpace (type: Ref, roots: Ref[] | undefined): boolean { + return roots === undefined || roots.includes(getRootType(hierarchy, type)) + } + + function isSelectableClass (_class: Class, roots: Ref[] | undefined, skipBaseTypes: boolean): boolean { + if (_class.label === undefined) return false + if (_class.kind !== ClassifierKind.CLASS) return false + if ((_class as MasterTag).removed === true) return false + if (!isAllowedBySpace(_class._id as Ref, roots)) return false + if (skipBaseTypes && isBaseTypeWithSubtypes(hierarchy, _class._id as Ref)) return false + return true + } + + function filterClasses ( + root: Ref, + roots: Ref[] | undefined, + skipBaseTypes: boolean + ): [DropdownIntlItem, DropdownIntlItem[]][] { + const descendants = hierarchy.getDescendants(root).filter((p) => p !== root) const added = new Set>>() const base = new Map>, Class[]>() for (const _id of descendants) { if (added.has(_id)) continue const _class = hierarchy.getClass(_id) - if (_class.label === undefined) continue - if (_class.kind !== ClassifierKind.CLASS) continue - if ((_class as MasterTag).removed === true) continue - if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, _id as Ref)) continue + if (!isSelectableClass(_class, roots, skipBaseTypes)) continue added.add(_id) const descendants = hierarchy.getDescendants(_id) const toAdd: Class[] = [] for (const desc of descendants) { if (added.has(desc)) continue const _class = hierarchy.getClass(desc) - if (_class.label === undefined) continue - if (_class.kind !== ClassifierKind.CLASS) continue - if ((_class as MasterTag).removed === true) continue - if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, desc as Ref)) continue + if (!isSelectableClass(_class, roots, skipBaseTypes)) continue added.add(desc) toAdd.push(_class) } @@ -82,9 +94,10 @@ } } - const classes = filterClasses() + let classes: [DropdownIntlItem, DropdownIntlItem[]][] = [] + $: classes = filterClasses(parent, allowedRootTypes, excludeBaseTypes) - $: if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) { + $: if (value != null && excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) { const nextType = getFirstCreatableSubtype(hierarchy, value) if (nextType !== undefined) { value = nextType @@ -92,12 +105,20 @@ } } - $: selectedClass = hierarchy.getClass(value) - $: selected = { - id: selectedClass._id, - label: selectedClass.label, - ...getIconProps(selectedClass) + $: if (value != null && !isAllowedBySpace(value, allowedRootTypes)) { + value = null + dispatch('change', value) } + + $: selectedClass = value != null ? hierarchy.getClass(value) : undefined + $: selected = + selectedClass !== undefined + ? { + id: selectedClass._id, + label: selectedClass.label, + ...getIconProps(selectedClass) + } + : undefined Date: Sun, 5 Jul 2026 22:04:21 +0500 Subject: [PATCH 03/15] feat: add viewlet integration and update attribute handling in various components (#10957) Signed-off-by: Denis Bykhov --- common/config/rush/pnpm-lock.yaml | 3 + models/server-card/package.json | 1 + models/server-card/src/index.ts | 11 ++ .../components/settings/ResultEditor.svelte | 2 +- plugins/process-resources/src/middleware.ts | 3 +- .../src/components/ViewletSetting.svelte | 118 ++++++++++++++++-- server-plugins/card-resources/src/index.ts | 114 ++++++++++++++++- server-plugins/card/src/index.ts | 1 + .../process-resources/src/functions.ts | 2 +- 9 files changed, 240 insertions(+), 15 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 928464ae94..0a14a2cd86 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -10446,6 +10446,9 @@ importers: '@hcengineering/server-notification': specifier: workspace:^0.7.0 version: link:../../server-plugins/notification + '@hcengineering/view': + specifier: workspace:^0.7.0 + version: link:../../plugins/view devDependencies: '@hcengineering/platform-rig': specifier: workspace:^0.7.21 diff --git a/models/server-card/package.json b/models/server-card/package.json index e1888aed2a..afbe444681 100644 --- a/models/server-card/package.json +++ b/models/server-card/package.json @@ -37,6 +37,7 @@ "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/model": "workspace:^0.7.17", "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/card": "workspace:^0.7.0", "@hcengineering/communication": "workspace:^0.7.0", "@hcengineering/server-notification": "workspace:^0.7.0", diff --git a/models/server-card/src/index.ts b/models/server-card/src/index.ts index 32d1be5798..828acd381a 100644 --- a/models/server-card/src/index.ts +++ b/models/server-card/src/index.ts @@ -21,6 +21,7 @@ import serverCard from '@hcengineering/server-card' import card from '@hcengineering/card' import communication from '@hcengineering/communication' import serverNotification from '@hcengineering/server-notification' +import view from '@hcengineering/view' export { serverCardId } from '@hcengineering/server-card' @@ -43,6 +44,16 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverCard.trigger.OnViewletUpdate, + isAsync: true, + txMatch: { + _class: core.class.TxUpdateDoc, + objectClass: view.class.Viewlet, + 'operations.config': { $exists: true } + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverCard.trigger.OnTagRemove, txMatch: { diff --git a/plugins/process-resources/src/components/settings/ResultEditor.svelte b/plugins/process-resources/src/components/settings/ResultEditor.svelte index ef6e4666d8..35ea436716 100644 --- a/plugins/process-resources/src/components/settings/ResultEditor.svelte +++ b/plugins/process-resources/src/components/settings/ResultEditor.svelte @@ -44,7 +44,7 @@ type } if (key !== undefined) { - const attr = client.getHierarchy().findAttribute(process.masterTag, key) + const attr = client.getModel().findAllSync(core.class.Attribute, { name: key })[0] if (attr?.label !== undefined) { name = await translate(attr.label, {}) result.name = name diff --git a/plugins/process-resources/src/middleware.ts b/plugins/process-resources/src/middleware.ts index 6fcf26ee64..dcc1497bd3 100644 --- a/plugins/process-resources/src/middleware.ts +++ b/plugins/process-resources/src/middleware.ts @@ -243,10 +243,9 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre results = await Promise.all( results.map(async (r) => { if (r.key !== undefined) { - const h = this.client.getHierarchy() const _process = this.client.getModel().findObject(execution.process) if (_process !== undefined) { - const attr = h.findAttribute(_process.masterTag, r.key) + const attr = this.client.getModel().findAllSync(core.class.Attribute, { name: r.key })[0] if (attr?.label !== undefined) { const name = await translate(attr.label, {}) return { ...r, name } diff --git a/plugins/view-resources/src/components/ViewletSetting.svelte b/plugins/view-resources/src/components/ViewletSetting.svelte index 468f2fb23a..4ee033794b 100644 --- a/plugins/view-resources/src/components/ViewletSetting.svelte +++ b/plugins/view-resources/src/components/ViewletSetting.svelte @@ -252,6 +252,67 @@ return typeof value === 'string' ? value : value?.key } + function getAttributeKey (key: string): string { + if (key.startsWith('$lookup.')) { + return key.slice('$lookup.'.length) + } + const dotIndex = key.lastIndexOf('.') + return dotIndex === -1 ? key : key.slice(dotIndex + 1) + } + + function isSourceAttribute (sourceClass: Ref>, key: string): boolean { + return hierarchy.getAllAttributes(sourceClass).has(getAttributeKey(key)) + } + + function syncConfigOrder ( + sourceClass: Ref>, + previousSourceConfig: Array, + sourceConfig: Array, + targetConfig: Array + ): Array { + const sourceKeys = new Set(sourceConfig.map(getKey).filter((it): it is string => it !== undefined)) + const previousSourceKeys = new Set(previousSourceConfig.map(getKey).filter((it): it is string => it !== undefined)) + const targetByKey = new Map>() + for (const [index, item] of targetConfig.entries()) { + const key = getKey(item) + if (key === undefined) continue + const items = targetByKey.get(key) ?? [] + items.push({ item, index }) + targetByKey.set(key, items) + } + + const sourceItems: Array = [] + const usedIndexes = new Set() + for (const sourceItem of sourceConfig) { + const key = getKey(sourceItem) + if (key === undefined) continue + + const targetItem = targetByKey.get(key)?.shift() + sourceItems.push(targetItem?.item ?? sourceItem) + if (targetItem !== undefined) { + usedIndexes.add(targetItem.index) + } + } + + const synced = [...sourceItems] + for (const [index, targetItem] of targetConfig.entries()) { + if (usedIndexes.has(index)) continue + + const key = getKey(targetItem) + if ( + key !== undefined && + !sourceKeys.has(key) && + (previousSourceKeys.has(key) || isSourceAttribute(sourceClass, key)) + ) { + continue + } + + synced.splice(Math.min(index, synced.length), 0, targetItem) + } + + return synced + } + function isExist (result: Config[], newValue: Config): boolean { if (!isAttribute(newValue)) return false const newValueKey = getKey(newValue.value) @@ -391,6 +452,45 @@ return preference === undefined ? result : setStatus(result, preference) } + async function upsertViewletPreference ( + viewletId: Ref, + config: Array + ): Promise { + const preference = preferences.find((p) => p.attachedTo === viewletId) + if (preference !== undefined) { + if (!deepEqual(preference.config, config)) { + await client.update(preference, { + config + }) + } + } else { + await client.createDoc(view.class.ViewletPreference, core.space.Workspace, { + attachedTo: viewletId, + config + }) + } + } + + async function syncChildViewletPreferences ( + sourceViewlet: Viewlet, + previousSourceConfig: Array, + sourceConfig: Array + ): Promise { + const descendants = new Set( + hierarchy.getDescendants(sourceViewlet.attachTo).filter((it) => it !== sourceViewlet.attachTo) + ) + for (const childViewlet of viewlets) { + if (!descendants.has(childViewlet.attachTo)) continue + + const preference = preferences.find((p) => p.attachedTo === childViewlet._id) + const targetConfig = preference?.config ?? childViewlet.config + const config = syncConfigOrder(sourceViewlet.attachTo, previousSourceConfig, sourceConfig, targetConfig) + if (deepEqual(targetConfig, config)) continue + + await upsertViewletPreference(childViewlet._id, config) + } + } + async function addAssociations ( result: Config[], _class: Ref>, @@ -437,16 +537,14 @@ } return value }) - const preference = preferences.find((p) => p.attachedTo === viewletId) - if (preference !== undefined) { - await client.update(preference, { - config - }) - } else { - await client.createDoc(view.class.ViewletPreference, core.space.Workspace, { - attachedTo: viewletId, - config - }) + const selectedViewlet = viewlets.find((it) => it._id === viewletId) + const previousSourceConfig = + preferences.find((p) => p.attachedTo === viewletId)?.config ?? selectedViewlet?.config ?? [] + + await upsertViewletPreference(viewletId, config) + + if (selectedViewlet !== undefined) { + await syncChildViewletPreferences(selectedViewlet, previousSourceConfig, config) } } diff --git a/server-plugins/card-resources/src/index.ts b/server-plugins/card-resources/src/index.ts index 9a8658714d..e420b1e055 100644 --- a/server-plugins/card-resources/src/index.ts +++ b/server-plugins/card-resources/src/index.ts @@ -61,9 +61,79 @@ import { getMetadata, translate } from '@hcengineering/platform' import { getEmployee, getPersonSpaces } from '@hcengineering/server-contact' import serverCore, { TriggerControl } from '@hcengineering/server-core' import setting from '@hcengineering/setting' -import view from '@hcengineering/view' +import view, { type BuildModelKey, type Viewlet } from '@hcengineering/view' import { workbenchId } from '@hcengineering/workbench' +type ViewletConfigItem = BuildModelKey | string +interface IndexedViewletConfigItem { + item: ViewletConfigItem + index: number +} + +function getViewletConfigKey (item: ViewletConfigItem): string { + return typeof item === 'string' ? item : item.key +} + +function getAttributeKey (key: string): string { + if (key.startsWith('$lookup.')) { + return key.slice('$lookup.'.length) + } + const dotIndex = key.lastIndexOf('.') + return dotIndex === -1 ? key : key.slice(dotIndex + 1) +} + +function isSourceAttribute (control: TriggerControl, sourceClass: Ref>, key: string): boolean { + return control.hierarchy.getAllAttributes(sourceClass).has(getAttributeKey(key)) +} + +function syncViewletConfigOrder ( + control: TriggerControl, + sourceClass: Ref>, + previousSourceConfig: ViewletConfigItem[], + sourceConfig: ViewletConfigItem[], + targetConfig: ViewletConfigItem[] +): ViewletConfigItem[] { + const sourceKeys = new Set(sourceConfig.map(getViewletConfigKey)) + const previousSourceKeys = new Set(previousSourceConfig.map(getViewletConfigKey)) + const targetByKey = new Map() + for (const [index, item] of targetConfig.entries()) { + const key = getViewletConfigKey(item) + const items = targetByKey.get(key) ?? [] + items.push({ item, index }) + targetByKey.set(key, items) + } + + const sourceItems: ViewletConfigItem[] = [] + const usedIndexes = new Set() + for (const sourceItem of sourceConfig) { + const key = getViewletConfigKey(sourceItem) + const targetItem = targetByKey.get(key)?.shift() + const item = targetItem?.item ?? sourceItem + sourceItems.push(item) + if (targetItem !== undefined) { + usedIndexes.add(targetItem.index) + } + } + + const synced = [...sourceItems] + for (const [index, targetItem] of targetConfig.entries()) { + if (usedIndexes.has(index)) continue + + const key = getViewletConfigKey(targetItem) + if (!sourceKeys.has(key) && (previousSourceKeys.has(key) || isSourceAttribute(control, sourceClass, key))) { + continue + } + + synced.splice(Math.min(index, synced.length), 0, targetItem) + } + + return synced +} + +function isConfigOrderChanged (current: ViewletConfigItem[], next: ViewletConfigItem[]): boolean { + return current.length !== next.length || current.some((item, index) => item !== next[index]) +} + async function OnAttribute (ctx: TxCreateDoc[], control: TriggerControl): Promise { const attr = TxProcessor.createDoc2Doc(ctx[0]) if (control.hierarchy.isDerived(attr.attributeOf, card.class.Card)) { @@ -159,6 +229,47 @@ async function OnAttributeRemove (ctx: TxRemoveDoc[], control: Tri return [] } +async function OnViewletUpdate (ctx: TxUpdateDoc[], control: TriggerControl): Promise { + const updateTx = ctx[0] + if (updateTx.space === core.space.DerivedTx) return [] + if (!Array.isArray(updateTx.operations.config)) return [] + + const sourceViewlet = (await control.findAll(control.ctx, view.class.Viewlet, { _id: updateTx.objectId }))[0] + if (sourceViewlet === undefined) return [] + if (!control.hierarchy.isDerived(sourceViewlet.attachTo, card.class.Card)) return [] + + const descendants = control.hierarchy + .getDescendants(sourceViewlet.attachTo) + .filter((it) => it !== sourceViewlet.attachTo) + if (descendants.length === 0) return [] + + const childViewlets = await control.findAll(control.ctx, view.class.Viewlet, { + attachTo: { $in: descendants }, + descriptor: sourceViewlet.descriptor, + variant: sourceViewlet.variant ?? { $exists: false } + }) + + const res: Tx[] = [] + for (const childViewlet of childViewlets) { + const config = syncViewletConfigOrder( + control, + sourceViewlet.attachTo, + sourceViewlet.config, + updateTx.operations.config, + childViewlet.config + ) + if (!isConfigOrderChanged(childViewlet.config, config)) continue + + res.push( + control.txFactory.createTxUpdateDoc(childViewlet._class, childViewlet.space, childViewlet._id, { + config + }) + ) + } + + return res +} + async function OnMasterTagRemove (ctx: TxUpdateDoc[], control: TriggerControl): Promise { const updateTx = ctx[0] if (updateTx.space === core.space.DerivedTx) return [] @@ -937,6 +1048,7 @@ export default async () => ({ trigger: { OnAttribute, OnAttributeRemove, + OnViewletUpdate, OnMasterTagCreate, OnMasterTagRemove, OnTagRemove, diff --git a/server-plugins/card/src/index.ts b/server-plugins/card/src/index.ts index 0f31d72cf2..1e0bdca707 100644 --- a/server-plugins/card/src/index.ts +++ b/server-plugins/card/src/index.ts @@ -37,6 +37,7 @@ export default plugin(serverCardId, { trigger: { OnAttribute: '' as Resource, OnAttributeRemove: '' as Resource, + OnViewletUpdate: '' as Resource, OnMasterTagCreate: '' as Resource, OnTagRemove: '' as Resource, OnMasterTagRemove: '' as Resource, diff --git a/server-plugins/process-resources/src/functions.ts b/server-plugins/process-resources/src/functions.ts index e14b37d168..32868b62e6 100644 --- a/server-plugins/process-resources/src/functions.ts +++ b/server-plugins/process-resources/src/functions.ts @@ -804,7 +804,7 @@ export async function CreateToDo ( todoResults.push({ _id: generateId() as any as ContextId, - name: attr.name, + name: attr.label, key: attr.name, type: attr.type }) From d2e92134cc539c734fbdfe178fd5b7e09518084f Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Mon, 6 Jul 2026 10:08:03 +0500 Subject: [PATCH 04/15] fix: deduplicate object and document lists in DocTable and RelationEditor to prevent rendering issues (#10958) Signed-off-by: Denis Bykhov --- .../src/components/DocTable.svelte | 19 ++++++++++++++--- .../src/components/RelationEditor.svelte | 21 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/plugins/view-resources/src/components/DocTable.svelte b/plugins/view-resources/src/components/DocTable.svelte index e2de1c218a..653b03e78d 100644 --- a/plugins/view-resources/src/components/DocTable.svelte +++ b/plugins/view-resources/src/components/DocTable.svelte @@ -47,7 +47,20 @@ const refs: HTMLElement[] = [] - $: refs.length = objects.length + $: uniqueObjects = deduplicate(objects) + + function deduplicate (list: Doc[] | undefined): Doc[] { + if (!list) return [] + const seen = new Set() + return list.filter((item) => { + if (item?._id == null) return false + if (seen.has(item._id)) return false + seen.add(item._id) + return true + }) + } + + $: refs.length = uniqueObjects.length $: viewlet = getViewlet(_class) @@ -210,9 +223,9 @@ {/if} - {#if objects.length > 0} + {#if uniqueObjects.length > 0} - {#each objects as object, row (object._id)} + {#each uniqueObjects as object, row (object._id)} () + return list.filter((item) => { + if (item?._id == null) return false + if (seen.has(item._id)) return false + seen.add(item._id) + return true + }) + } + function getCreate (): ObjectCreate | undefined { const factory = client.getHierarchy().classHierarchyMixin(_class, view.mixin.ObjectFactory) if (factory) { @@ -53,7 +66,7 @@ function add (): void { const create = getCreate() const isVersionable = client.getHierarchy().classHierarchyMixin(_class, core.mixin.VersionableClass) !== undefined - const baseQuery = { _id: { $nin: docs.map((p) => p._id) } } + const baseQuery = { _id: { $nin: uniqueDocs.map((p) => p._id) } } const docQuery = isVersionable ? { isLatest: true, ...baseQuery } : baseQuery showPopup( ObjectBoxPopup, @@ -120,7 +133,7 @@ return direction === 'B' } - $: allowToCreate = isAllowedToCreate(association, docs, direction) + $: allowToCreate = isAllowedToCreate(association, uniqueDocs, direction) $: classLabel = client.getHierarchy().getClass(_class).label @@ -139,9 +152,9 @@ - {#if docs?.length > 0 && config != null} + {#if uniqueDocs?.length > 0 && config != null} - + {:else if !readonly}
Date: Mon, 6 Jul 2026 13:29:21 +0700 Subject: [PATCH 05/15] Allow to export documents without children (#10909) * Allow to export documents without children Signed-off-by: Artem Savchenko * Fix tests Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- plugins/export-assets/lang/cs.json | 2 + plugins/export-assets/lang/de.json | 2 + plugins/export-assets/lang/en.json | 2 + plugins/export-assets/lang/es.json | 2 + plugins/export-assets/lang/fr.json | 2 + plugins/export-assets/lang/it.json | 2 + plugins/export-assets/lang/ja.json | 2 + plugins/export-assets/lang/ko.json | 2 + plugins/export-assets/lang/pt-br.json | 2 + plugins/export-assets/lang/pt.json | 2 + plugins/export-assets/lang/ru.json | 2 + plugins/export-assets/lang/tr.json | 2 + plugins/export-assets/lang/zh.json | 2 + .../components/ExportToWorkspaceModal.svelte | 23 ++- plugins/export-resources/src/export.ts | 6 +- plugins/export-resources/src/plugin.ts | 4 +- .../src/__tests__/relation-exporter.test.ts | 9 +- .../src/__tests__/workspace-exporter.test.ts | 143 ++++++++++++++++++ services/export/pod-export/src/server.ts | 5 +- .../src/workspace/document-exporter.ts | 32 ++-- .../src/workspace/relation-exporter.ts | 42 +++-- .../export/pod-export/src/workspace/types.ts | 4 + .../src/workspace/workspace-exporter.ts | 10 +- 23 files changed, 268 insertions(+), 36 deletions(-) diff --git a/plugins/export-assets/lang/cs.json b/plugins/export-assets/lang/cs.json index 64de0ca22c..f464a9fc35 100644 --- a/plugins/export-assets/lang/cs.json +++ b/plugins/export-assets/lang/cs.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Pouze platné dokumenty", "ExportFilterSkipArchivedObsolete": "Vše kromě archivovaných a zastaralých", "ExportFilterAll": "Všechny dokumenty", + "ExportChildDocuments": "Exportovat podřízené dokumenty", + "ExportChildDocumentsDescription": "Také exportovat dokumenty připojené k vybraným", "ExportResultRecordTitle": "Export dokumentů z pracovního prostoru {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/de.json b/plugins/export-assets/lang/de.json index 1fe8f24d0c..721ab90b39 100644 --- a/plugins/export-assets/lang/de.json +++ b/plugins/export-assets/lang/de.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Nur wirksame Dokumente exportieren", "ExportFilterSkipArchivedObsolete": "Alle Dokumente außer archivierten und veralteten", "ExportFilterAll": "Alle Dokumente", + "ExportChildDocuments": "Untergeordnete Dokumente exportieren", + "ExportChildDocumentsDescription": "Auch Dokumente exportieren, die an die ausgewählten angehängt sind", "ExportResultRecordTitle": "Export von Dokumenten aus dem Arbeitsbereich {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/en.json b/plugins/export-assets/lang/en.json index 4d54dc5009..c7dae4bbd5 100644 --- a/plugins/export-assets/lang/en.json +++ b/plugins/export-assets/lang/en.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Export only effective documents", "ExportFilterSkipArchivedObsolete": "All documents except archived and obsolete", "ExportFilterAll": "All documents", + "ExportChildDocuments": "Export child documents", + "ExportChildDocumentsDescription": "Also export documents attached to the selected ones", "ExportResultRecordTitle": "Export of documents from {workspace} workspace ({date})" } } diff --git a/plugins/export-assets/lang/es.json b/plugins/export-assets/lang/es.json index db2c203cea..dbf975aa75 100644 --- a/plugins/export-assets/lang/es.json +++ b/plugins/export-assets/lang/es.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Solo documentos efectivos", "ExportFilterSkipArchivedObsolete": "Todos excepto archivados y obsoletos", "ExportFilterAll": "Todos los documentos", + "ExportChildDocuments": "Exportar documentos secundarios", + "ExportChildDocumentsDescription": "Exportar también los documentos adjuntos a los seleccionados", "ExportResultRecordTitle": "Exportación de documentos del espacio de trabajo {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/fr.json b/plugins/export-assets/lang/fr.json index dfeb48f579..2587e73342 100644 --- a/plugins/export-assets/lang/fr.json +++ b/plugins/export-assets/lang/fr.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Seulement les documents effectifs", "ExportFilterSkipArchivedObsolete": "Tous sauf archivés et obsolètes", "ExportFilterAll": "Tous les documents", + "ExportChildDocuments": "Exporter les documents enfants", + "ExportChildDocumentsDescription": "Exporter aussi les documents rattachés aux éléments sélectionnés", "ExportResultRecordTitle": "Export de documents depuis l'espace de travail {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/it.json b/plugins/export-assets/lang/it.json index 1098bac228..7972c44049 100644 --- a/plugins/export-assets/lang/it.json +++ b/plugins/export-assets/lang/it.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Solo documenti effettivi", "ExportFilterSkipArchivedObsolete": "Tutti tranne archiviati e obsoleti", "ExportFilterAll": "Tutti i documenti", + "ExportChildDocuments": "Esporta documenti figlio", + "ExportChildDocumentsDescription": "Esporta anche i documenti collegati a quelli selezionati", "ExportResultRecordTitle": "Esportazione di documenti dallo spazio di lavoro {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/ja.json b/plugins/export-assets/lang/ja.json index 015535c90f..5bc3f4de6e 100644 --- a/plugins/export-assets/lang/ja.json +++ b/plugins/export-assets/lang/ja.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "有効なドキュメントのみ", "ExportFilterSkipArchivedObsolete": "アーカイブ・廃止を除くすべて", "ExportFilterAll": "すべてのドキュメント", + "ExportChildDocuments": "子ドキュメントをエクスポート", + "ExportChildDocumentsDescription": "選択したドキュメントに添付されたドキュメントもエクスポートします", "ExportResultRecordTitle": "ワークスペース {workspace} からのドキュメントのエクスポート ({date})" } } diff --git a/plugins/export-assets/lang/ko.json b/plugins/export-assets/lang/ko.json index 5147c679c3..0812b3cc03 100644 --- a/plugins/export-assets/lang/ko.json +++ b/plugins/export-assets/lang/ko.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "유효한 문서만", "ExportFilterSkipArchivedObsolete": "보관/폐기 제외 전체", "ExportFilterAll": "모든 문서", + "ExportChildDocuments": "하위 문서 내보내기", + "ExportChildDocumentsDescription": "선택한 문서에 첨부된 문서도 함께 내보냅니다", "ExportResultRecordTitle": "{workspace} 워크스페이스 문서 내보내기 ({date})" } } diff --git a/plugins/export-assets/lang/pt-br.json b/plugins/export-assets/lang/pt-br.json index 5db9373361..aa2b67a7cb 100644 --- a/plugins/export-assets/lang/pt-br.json +++ b/plugins/export-assets/lang/pt-br.json @@ -54,6 +54,8 @@ "ExportFilterEffectiveOnly": "Apenas documentos efetivos", "ExportFilterSkipArchivedObsolete": "Todos exceto arquivados e obsoletos", "ExportFilterAll": "Todos os documentos", + "ExportChildDocuments": "Exportar documentos filhos", + "ExportChildDocumentsDescription": "Exportar também os documentos anexados aos selecionados", "ExportResultRecordTitle": "Exportação de documentos do espaço de trabalho {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/pt.json b/plugins/export-assets/lang/pt.json index 883ecd1ddf..ab3e3ed599 100644 --- a/plugins/export-assets/lang/pt.json +++ b/plugins/export-assets/lang/pt.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Apenas documentos efetivos", "ExportFilterSkipArchivedObsolete": "Todos exceto arquivados e obsoletos", "ExportFilterAll": "Todos os documentos", + "ExportChildDocuments": "Exportar documentos filhos", + "ExportChildDocumentsDescription": "Exportar também os documentos anexados aos selecionados", "ExportResultRecordTitle": "Exportação de documentos do espaço de trabalho {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/ru.json b/plugins/export-assets/lang/ru.json index ff3b62de91..590bfa996f 100644 --- a/plugins/export-assets/lang/ru.json +++ b/plugins/export-assets/lang/ru.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Только действующие документы", "ExportFilterSkipArchivedObsolete": "Все кроме архивных и устаревших", "ExportFilterAll": "Все документы", + "ExportChildDocuments": "Экспортировать дочерние документы", + "ExportChildDocumentsDescription": "Также экспортировать документы, прикреплённые к выбранным", "ExportResultRecordTitle": "Экспорт документов из пространства {workspace} ({date})" } } diff --git a/plugins/export-assets/lang/tr.json b/plugins/export-assets/lang/tr.json index 3f897ebf75..c4295c1ba7 100644 --- a/plugins/export-assets/lang/tr.json +++ b/plugins/export-assets/lang/tr.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "Yalnızca geçerli belgeler", "ExportFilterSkipArchivedObsolete": "Arşivlenmiş ve geçersiz hariç tümü", "ExportFilterAll": "Tüm belgeler", + "ExportChildDocuments": "Alt belgeleri dışa aktar", + "ExportChildDocumentsDescription": "Seçili belgelere ekli belgeleri de dışa aktar", "ExportResultRecordTitle": "{workspace} çalışma alanından belge dışa aktarma ({date})" } } diff --git a/plugins/export-assets/lang/zh.json b/plugins/export-assets/lang/zh.json index e8968fe0a0..52fed6ed51 100644 --- a/plugins/export-assets/lang/zh.json +++ b/plugins/export-assets/lang/zh.json @@ -53,6 +53,8 @@ "ExportFilterEffectiveOnly": "仅有效文档", "ExportFilterSkipArchivedObsolete": "除已归档和已过时外的全部", "ExportFilterAll": "全部文档", + "ExportChildDocuments": "导出子文档", + "ExportChildDocumentsDescription": "同时导出附加到所选文档上的文档", "ExportResultRecordTitle": "从工作区 {workspace} 导出文档 ({date})" } } diff --git a/plugins/export-resources/src/components/ExportToWorkspaceModal.svelte b/plugins/export-resources/src/components/ExportToWorkspaceModal.svelte index 19dfc4a85a..2d9d39b7d0 100644 --- a/plugins/export-resources/src/components/ExportToWorkspaceModal.svelte +++ b/plugins/export-resources/src/components/ExportToWorkspaceModal.svelte @@ -25,7 +25,7 @@ type Class } from '@hcengineering/core' import { Card, getClient, getCurrentWorkspaceUuid } from '@hcengineering/presentation' - import { DropdownLabels, DropdownLabelsIntl, Label } from '@hcengineering/ui' + import { DropdownLabels, DropdownLabelsIntl, Label, ToggleWithLabel } from '@hcengineering/ui' import { getResource } from '@hcengineering/platform' import login from '@hcengineering/login' import { shouldSkipDocument, isEffectiveDocument } from '@hcengineering/export' @@ -52,6 +52,11 @@ type ExportFilterMode = 'effectiveOnly' | 'skipArchivedObsolete' | 'all' let exportFilterMode: ExportFilterMode = 'effectiveOnly' + // Whether to recursively export child (collection) documents of the selected docs. + // Hidden and forced to true when exporting an entire space — the space already + // enumerates every document inside it. + let includeChildren: boolean = false + const exportFilterItems = [ { id: 'effectiveOnly' as const, label: plugin.string.ExportFilterEffectiveOnly }, { id: 'skipArchivedObsolete' as const, label: plugin.string.ExportFilterSkipArchivedObsolete }, @@ -142,6 +147,10 @@ const effectiveDocs = projectDocExport === true ? await getExportDocuments() : filteredSelectedDocs + // When exporting an entire space, child documents are picked up by the + // space-wide traversal — force the flag on regardless of UI state. + const effectiveIncludeChildren = spaceExport === true ? true : includeChildren + void exportToWorkspace( _class, exportQuery, @@ -149,7 +158,8 @@ targetWorkspace, undefined, exportFilterMode === 'skipArchivedObsolete', - exportFilterMode === 'effectiveOnly' + exportFilterMode === 'effectiveOnly', + effectiveIncludeChildren ) loading = false dispatch('close', true) @@ -191,5 +201,14 @@
diff --git a/plugins/export-resources/src/export.ts b/plugins/export-resources/src/export.ts index 69333a989b..12c227e431 100644 --- a/plugins/export-resources/src/export.ts +++ b/plugins/export-resources/src/export.ts @@ -26,7 +26,8 @@ export async function exportToWorkspace ( targetWorkspace: string | undefined, relations?: RelationDefinition[] | undefined, skipDeletedObsolete?: boolean, - exportOnlyEffective?: boolean + exportOnlyEffective?: boolean, + includeChildren?: boolean ): Promise { const lang = getCurrentLanguage() @@ -73,7 +74,8 @@ export async function exportToWorkspace ( _class, fieldMappers, skipDeletedObsolete, - exportOnlyEffective + exportOnlyEffective, + includeChildren } if (relations != null) { body.relations = relations diff --git a/plugins/export-resources/src/plugin.ts b/plugins/export-resources/src/plugin.ts index 34f8804ce0..6d77384dfc 100644 --- a/plugins/export-resources/src/plugin.ts +++ b/plugins/export-resources/src/plugin.ts @@ -56,6 +56,8 @@ export default mergeIds(exportId, exportPlugin, { ExportFilterMode: '' as IntlString, ExportFilterEffectiveOnly: '' as IntlString, ExportFilterSkipArchivedObsolete: '' as IntlString, - ExportFilterAll: '' as IntlString + ExportFilterAll: '' as IntlString, + ExportChildDocuments: '' as IntlString, + ExportChildDocumentsDescription: '' as IntlString } }) diff --git a/services/export/pod-export/src/__tests__/relation-exporter.test.ts b/services/export/pod-export/src/__tests__/relation-exporter.test.ts index 2dc20315be..fa2e8ed339 100644 --- a/services/export/pod-export/src/__tests__/relation-exporter.test.ts +++ b/services/export/pod-export/src/__tests__/relation-exporter.test.ts @@ -127,7 +127,8 @@ describe('RelationExporter sourceClass', () => { hierarchy, lowLevel, expect.any(Map), - relations + relations, + false ) }) @@ -178,7 +179,8 @@ describe('RelationExporter sourceClass', () => { hierarchy, lowLevel, expect.any(Map), - relations + relations, + false ) }) @@ -219,7 +221,8 @@ describe('RelationExporter sourceClass', () => { hierarchy, lowLevel, expect.any(Map), - relations + relations, + false ) }) diff --git a/services/export/pod-export/src/__tests__/workspace-exporter.test.ts b/services/export/pod-export/src/__tests__/workspace-exporter.test.ts index cfd07b2f83..7873797622 100644 --- a/services/export/pod-export/src/__tests__/workspace-exporter.test.ts +++ b/services/export/pod-export/src/__tests__/workspace-exporter.test.ts @@ -541,6 +541,149 @@ describe('CrossWorkspaceExporter', () => { expect(attachedToArg).not.toBe(SOURCE_DOC_1) } }) + + it('should NOT export child collection documents when includeChildren is false', async () => { + const sourceSpace = createMockSpace(SOURCE_SPACE_ID, 'Test Space') + const parentDoc = createMockDoc(SOURCE_DOC_1, mockDocClass, SOURCE_SPACE_ID) + const attachedDoc = createMockAttachedDoc( + SOURCE_ATTACHED_1, + mockAttachedDocClass, + SOURCE_SPACE_ID, + SOURCE_DOC_1, + mockDocClass, + 'children', + { title: 'Attached Doc' } + ) + + const hierarchy = createMockHierarchy({ + domains: new Map([ + [mockDocClass, 'test_domain'], + [mockAttachedDocClass, 'test_domain'], + [core.class.Space, 'space_domain'] + ]), + isDerived: new Map([[mockAttachedDocClass, true]]), + attributes: new Map([ + [ + mockDocClass, + new Map([['children', { type: { _class: core.class.Collection, of: mockAttachedDocClass } }]]) + ], + [ + mockAttachedDocClass, + new Map([ + ['attachedTo', { type: { _class: core.class.RefTo } }], + ['attachedToClass', { type: { _class: 'core:class:TypeString' as Ref> } }], + ['collection', { type: { _class: 'core:class:TypeString' as Ref> } }] + ]) + ] + ]) + }) + + const lowLevelStorage = createMockLowLevelStorage( + new Map([ + ['test_domain', [parentDoc, attachedDoc]], + ['space_domain', [sourceSpace]] + ]) + ) + + const targetClient = createMockTxOperations([], [], hierarchy) + const pipelineFactory = createMockPipelineFactory(hierarchy, lowLevelStorage) + + const exporter = new CrossWorkspaceExporter( + mockContext, + pipelineFactory, + targetClient, + mockStorage, + undefined, + createWorkspaceIds('source-ws'), + createWorkspaceIds('target-ws') + ) + + // Query the parent class only — and explicitly opt OUT of child export. + const result = await exporter.export({ + sourceWorkspace: createWorkspaceIds('source-ws'), + targetWorkspace: createWorkspaceIds('target-ws'), + sourceQuery: { _id: SOURCE_DOC_1 } as any, + _class: mockDocClass, + includeChildren: false + }) + + expect(result.success).toBe(true) + // The parent doc must be created via createDoc, but the attached collection + // item must NOT go through addCollection because includeChildren is false. + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((targetClient.addCollection as jest.Mock).mock.calls.length).toBe(0) + }) + + it('should export child collection documents when includeChildren is true', async () => { + const sourceSpace = createMockSpace(SOURCE_SPACE_ID, 'Test Space') + const parentDoc = createMockDoc(SOURCE_DOC_1, mockDocClass, SOURCE_SPACE_ID) + const attachedDoc = createMockAttachedDoc( + SOURCE_ATTACHED_1, + mockAttachedDocClass, + SOURCE_SPACE_ID, + SOURCE_DOC_1, + mockDocClass, + 'children', + { title: 'Attached Doc' } + ) + + const hierarchy = createMockHierarchy({ + domains: new Map([ + [mockDocClass, 'test_domain'], + [mockAttachedDocClass, 'test_domain'], + [core.class.Space, 'space_domain'] + ]), + isDerived: new Map([[mockAttachedDocClass, true]]), + attributes: new Map([ + [ + mockDocClass, + new Map([['children', { type: { _class: core.class.Collection, of: mockAttachedDocClass } }]]) + ], + [ + mockAttachedDocClass, + new Map([ + ['attachedTo', { type: { _class: core.class.RefTo } }], + ['attachedToClass', { type: { _class: 'core:class:TypeString' as Ref> } }], + ['collection', { type: { _class: 'core:class:TypeString' as Ref> } }] + ]) + ] + ]) + }) + + const lowLevelStorage = createMockLowLevelStorage( + new Map([ + ['test_domain', [parentDoc, attachedDoc]], + ['space_domain', [sourceSpace]] + ]) + ) + + const targetClient = createMockTxOperations([], [], hierarchy) + const pipelineFactory = createMockPipelineFactory(hierarchy, lowLevelStorage) + + const exporter = new CrossWorkspaceExporter( + mockContext, + pipelineFactory, + targetClient, + mockStorage, + undefined, + createWorkspaceIds('source-ws'), + createWorkspaceIds('target-ws') + ) + + // Query the parent class only — and opt IN to recursive child export. + const result = await exporter.export({ + sourceWorkspace: createWorkspaceIds('source-ws'), + targetWorkspace: createWorkspaceIds('target-ws'), + sourceQuery: { _id: SOURCE_DOC_1 } as any, + _class: mockDocClass, + includeChildren: true + }) + + expect(result.success).toBe(true) + // With the flag on, the attached collection item must be created via addCollection. + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((targetClient.addCollection as jest.Mock).mock.calls.length).toBeGreaterThan(0) + }) }) describe('Forward Relations', () => { diff --git a/services/export/pod-export/src/server.ts b/services/export/pod-export/src/server.ts index 0016569bbf..82e449a45e 100644 --- a/services/export/pod-export/src/server.ts +++ b/services/export/pod-export/src/server.ts @@ -414,7 +414,8 @@ export function createServer ( relations: rawRelations, fieldMappers, skipDeletedObsolete, - exportOnlyEffective + exportOnlyEffective, + includeChildren }: { targetWorkspace: WorkspaceUuid _class: Ref> @@ -427,6 +428,7 @@ export function createServer ( fieldMappers?: Record> skipDeletedObsolete?: boolean exportOnlyEffective?: boolean + includeChildren?: boolean } = req.body // Validate required parameters @@ -543,6 +545,7 @@ export function createServer ( fieldMappers, skipDeletedObsolete: skipDeletedObsolete ?? true, exportOnlyEffective: exportOnlyEffective ?? false, + includeChildren: includeChildren ?? false, customHandlers: [createProductVersionHandler()] } diff --git a/services/export/pod-export/src/workspace/document-exporter.ts b/services/export/pod-export/src/workspace/document-exporter.ts index a73979a2f1..86344d1fe7 100644 --- a/services/export/pod-export/src/workspace/document-exporter.ts +++ b/services/export/pod-export/src/workspace/document-exporter.ts @@ -75,7 +75,8 @@ export class DocumentExporter { sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, existingDocsMap: Map, Doc>, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean = false ): Promise { if (this.state.processingDocs.has(doc._id)) { return false @@ -127,7 +128,8 @@ export class DocumentExporter { conflictStrategy, includeAttachments, sourceHierarchy, - sourceLowLevel + sourceLowLevel, + includeChildren ) // Create the document @@ -145,7 +147,8 @@ export class DocumentExporter { conflictStrategy, includeAttachments, sourceHierarchy, - sourceLowLevel + sourceLowLevel, + includeChildren ) await this.exportSpaceRelations( doc, @@ -154,7 +157,8 @@ export class DocumentExporter { includeAttachments, sourceHierarchy, sourceLowLevel, - relations + relations, + includeChildren ) // Handle attachments @@ -165,8 +169,12 @@ export class DocumentExporter { // Handle collaborative content blobs (e.g., document content) await this.attachmentExporter.exportCollaborativeContent(doc, sourceHierarchy) - // Handle collections (child documents) - await this.exportCollections(doc, targetId, sourceHierarchy, sourceLowLevel, relations) + // Handle collections (child documents) — only when explicitly requested. + // When disabled, only the top-level documents matched by the export query + // are exported and their attached collection items are skipped. + if (includeChildren) { + await this.exportCollections(doc, targetId, sourceHierarchy, sourceLowLevel, relations, includeChildren) + } return true } catch (err: any) { @@ -230,7 +238,8 @@ export class DocumentExporter { includeAttachments: boolean, sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ): Promise { try { if (this.relationExporter === undefined) { @@ -256,7 +265,8 @@ export class DocumentExporter { conflictStrategy, includeAttachments, sourceHierarchy, - sourceLowLevel + sourceLowLevel, + includeChildren ) } catch (err: any) { this.context.error(`Failed to export relations for space ${space}:`, { @@ -310,7 +320,8 @@ export class DocumentExporter { targetDocId: Ref, sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ): Promise { const attributes = sourceHierarchy.getAllAttributes(sourceDoc._class) @@ -349,7 +360,8 @@ export class DocumentExporter { sourceHierarchy, sourceLowLevel, new Map(), - relations + relations, + includeChildren ) } catch (err: any) { this.context.error(`Failed to export collection item ${collectionDoc._id}:`, { diff --git a/services/export/pod-export/src/workspace/relation-exporter.ts b/services/export/pod-export/src/workspace/relation-exporter.ts index 99de990cc4..cfebb471e7 100644 --- a/services/export/pod-export/src/workspace/relation-exporter.ts +++ b/services/export/pod-export/src/workspace/relation-exporter.ts @@ -34,7 +34,8 @@ export type ExportDocumentFn = ( sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, existingDocsMap: Map, Doc>, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ) => Promise /** @@ -56,7 +57,8 @@ export class RelationExporter { conflictStrategy: 'skip' | 'duplicate', includeAttachments: boolean, sourceHierarchy: Hierarchy, - sourceLowLevel: LowLevelStorage + sourceLowLevel: LowLevelStorage, + includeChildren: boolean = false ): Promise { for (const relation of relations) { const direction = relation.direction ?? 'forward' @@ -71,7 +73,8 @@ export class RelationExporter { includeAttachments, sourceHierarchy, sourceLowLevel, - relations + relations, + includeChildren ) } catch (err: any) { this.context.error(`Failed to export forward relation ${relation.field} for document ${doc._id}:`, { @@ -92,7 +95,8 @@ export class RelationExporter { conflictStrategy: 'skip' | 'duplicate', includeAttachments: boolean, sourceHierarchy: Hierarchy, - sourceLowLevel: LowLevelStorage + sourceLowLevel: LowLevelStorage, + includeChildren: boolean = false ): Promise { for (const relation of relations) { const direction = relation.direction ?? 'forward' @@ -107,7 +111,8 @@ export class RelationExporter { includeAttachments, sourceHierarchy, sourceLowLevel, - relations + relations, + includeChildren ) } catch (err: any) { this.context.error(`Failed to export inverse relation ${relation.field} for document ${doc._id}:`, { @@ -125,7 +130,8 @@ export class RelationExporter { conflictStrategy: 'skip' | 'duplicate', includeAttachments: boolean, sourceHierarchy: Hierarchy, - sourceLowLevel: LowLevelStorage + sourceLowLevel: LowLevelStorage, + includeChildren: boolean = false ): Promise { await this.exportForwardRelations( doc, @@ -133,7 +139,8 @@ export class RelationExporter { conflictStrategy, includeAttachments, sourceHierarchy, - sourceLowLevel + sourceLowLevel, + includeChildren ) await this.exportInverseRelations( doc, @@ -141,7 +148,8 @@ export class RelationExporter { conflictStrategy, includeAttachments, sourceHierarchy, - sourceLowLevel + sourceLowLevel, + includeChildren ) } @@ -152,7 +160,8 @@ export class RelationExporter { includeAttachments: boolean, sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ): Promise { if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) { return @@ -185,7 +194,8 @@ export class RelationExporter { includeAttachments, sourceHierarchy, sourceLowLevel, - relations + relations, + includeChildren ) } } @@ -197,7 +207,8 @@ export class RelationExporter { includeAttachments: boolean, sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ): Promise { if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) { return @@ -232,7 +243,8 @@ export class RelationExporter { sourceHierarchy, sourceLowLevel, new Map(), - relations + relations, + includeChildren ) } } @@ -244,7 +256,8 @@ export class RelationExporter { includeAttachments: boolean, sourceHierarchy: Hierarchy, sourceLowLevel: LowLevelStorage, - relations: RelationDefinition[] + relations: RelationDefinition[], + includeChildren: boolean ): Promise { if (this.state.idMapping.has(ref)) { return @@ -274,7 +287,8 @@ export class RelationExporter { sourceHierarchy, sourceLowLevel, new Map(), - relations + relations, + includeChildren ) } } diff --git a/services/export/pod-export/src/workspace/types.ts b/services/export/pod-export/src/workspace/types.ts index 369b6bde29..d36202d0f5 100644 --- a/services/export/pod-export/src/workspace/types.ts +++ b/services/export/pod-export/src/workspace/types.ts @@ -84,6 +84,10 @@ export interface ExportOptions { skipDeletedObsolete?: boolean // Whether to export only documents with effective status exportOnlyEffective?: boolean + // Whether to recursively export child (collection) documents of each exported doc. + // When false, only the documents matched by the top-level query are exported and + // their attached collection items are skipped. + includeChildren?: boolean // Class-specific export handlers, applied before the default flow. Useful // for collapsing or deduplicating documents (e.g. ProductVersion). customHandlers?: CustomExportHandler[] diff --git a/services/export/pod-export/src/workspace/workspace-exporter.ts b/services/export/pod-export/src/workspace/workspace-exporter.ts index 62eed3501c..ddf6d606fe 100644 --- a/services/export/pod-export/src/workspace/workspace-exporter.ts +++ b/services/export/pod-export/src/workspace/workspace-exporter.ts @@ -98,7 +98,8 @@ export class CrossWorkspaceExporter { sourceHierarchy, sourceLowLevel, existingDocsMap, - relations + relations, + includeChildren ) => { return await this.documentExporter.exportDocument( doc, @@ -107,7 +108,8 @@ export class CrossWorkspaceExporter { sourceHierarchy, sourceLowLevel, existingDocsMap, - relations + relations, + includeChildren ) } ) @@ -128,6 +130,7 @@ export class CrossWorkspaceExporter { fieldMappers = {}, skipDeletedObsolete = true, exportOnlyEffective = false, + includeChildren = false, customHandlers = [] } = options @@ -258,7 +261,8 @@ export class CrossWorkspaceExporter { hierarchy, lowLevelStorage, existingDocsMap, - resolvedRelations + resolvedRelations, + includeChildren ) if (exported) { result.exportedCount++ From dfe7d3d17c7cc3dfa32fc5218547181faddf89c8 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 6 Jul 2026 13:30:21 +0700 Subject: [PATCH 06/15] feat: Add ability to schedule notifications (#10789) * feat: Add ability to schedule notifications Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko * Add docker file Signed-off-by: Artem Savchenko * Rename pod Signed-off-by: Artem Savchenko * Add debug logging Signed-off-by: Artem Savchenko * Reminder fixes Signed-off-by: Artem Savchenko * Fix reminders Signed-off-by: Artem Savchenko * Support reminders for all events Signed-off-by: Artem Savchenko * Clean up Signed-off-by: Artem Savchenko * Support for project todo Signed-off-by: Artem Savchenko * Fix mismatched dependency Signed-off-by: Artem Savchenko * Use base event class Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .gitignore | 3 +- README.md | 6 + common/config/rush/pnpm-lock.yaml | 109 ++++ common/scripts/docker.sh | 3 +- dev/docker-compose.yaml | 18 + .../packages/analytics-service/src/logging.ts | 9 +- .../src/__tests__/telemetry.test.ts | 1 + .../measurements-otlp/src/telemetry.ts | 33 +- .../src/__tests__/context.test.ts | 56 ++ .../core/packages/measurements/src/context.ts | 63 +- .../core/packages/measurements/src/types.ts | 10 + .../src/__tests__/storage.test.ts | 1 + .../server/packages/kafka/src/index.ts | 7 +- models/calendar/src/index.ts | 23 +- models/calendar/src/plugin.ts | 1 - plugins/calendar/src/index.ts | 1 + .../src/components/CreateToDoPopup.svelte | 10 +- .../src/components/TodoWorkslots.svelte | 52 +- pods/server/src/rpc.ts | 31 +- rush.json | 5 + .../calendar-resources/src/index.ts | 129 ++++ .../src/reminderScheduling.test.ts | 243 ++++++++ .../notification-resources/src/push.ts | 46 +- .../src/__tests__/attachments.test.ts | 2 + .../pod-events-processor/.eslintrc.js | 8 + .../pod-events-processor/Dockerfile | 6 + .../pod-events-processor/jest.config.js | 8 + .../pod-events-processor/package.json | 71 +++ .../src/__tests__/clientCache.test.ts | 77 +++ .../src/__tests__/worker.test.ts | 579 ++++++++++++++++++ .../pod-events-processor/src/client.ts | 78 +++ .../pod-events-processor/src/clientCache.ts | 72 +++ .../pod-events-processor/src/config.ts | 38 ++ .../pod-events-processor/src/index.ts | 98 +++ .../pod-events-processor/src/types.ts | 31 + .../pod-events-processor/src/worker.ts | 233 +++++++ .../pod-events-processor/tsconfig.json | 12 + services/worker/README.md | 3 +- services/worker/src/config.ts | 4 +- services/worker/src/worker.ts | 34 +- 40 files changed, 2157 insertions(+), 57 deletions(-) create mode 100644 server-plugins/calendar-resources/src/reminderScheduling.test.ts create mode 100644 services/notification/pod-events-processor/.eslintrc.js create mode 100644 services/notification/pod-events-processor/Dockerfile create mode 100644 services/notification/pod-events-processor/jest.config.js create mode 100644 services/notification/pod-events-processor/package.json create mode 100644 services/notification/pod-events-processor/src/__tests__/clientCache.test.ts create mode 100644 services/notification/pod-events-processor/src/__tests__/worker.test.ts create mode 100644 services/notification/pod-events-processor/src/client.ts create mode 100644 services/notification/pod-events-processor/src/clientCache.ts create mode 100644 services/notification/pod-events-processor/src/config.ts create mode 100644 services/notification/pod-events-processor/src/index.ts create mode 100644 services/notification/pod-events-processor/src/types.ts create mode 100644 services/notification/pod-events-processor/src/worker.ts create mode 100644 services/notification/pod-events-processor/tsconfig.json diff --git a/.gitignore b/.gitignore index 7e45a86eeb..1ec48e0f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ dev/tool/history.json /combined_dependencies .tmp ws-tests/docker-compose.override.yml -.cursor/* \ No newline at end of file +.cursor/* +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index e7c0b91396..ac914af7ce 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ For detailed information about the platform architecture, services, and their in - [Docker](https://docs.docker.com/get-docker/) - [Docker Compose](https://docs.docker.com/compose/install/) +If you use `nvm`, run this after entering the repo to align your shell with the repository Node version: + +```bash +nvm use +``` + ## Verification To verify the installation, perform the following checks in your terminal: diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 0a14a2cd86..57a469ad1f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -40218,6 +40218,115 @@ importers: specifier: ^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: dependencies: '@hcengineering/analytics': diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index 3f6de5a828..aa3326de40 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -58,5 +58,6 @@ else --to @hcengineering/pod-process \ --to @hcengineering/pod-rating \ --to @hcengineering/pod-payment \ - --to @hcengineering/pod-worker + --to @hcengineering/pod-worker \ + --to @hcengineering/pod-events-processor fi diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index bc4429f703..2774fcd71a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -595,6 +595,24 @@ services: - QUEUE_CONFIG=${QUEUE_CONFIG} - QUEUE_REGION=cockroach 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: # image: hardcoreeng/translate # extra_hosts: diff --git a/foundations/core/packages/analytics-service/src/logging.ts b/foundations/core/packages/analytics-service/src/logging.ts index 2ccf0db338..c9f45f5be9 100644 --- a/foundations/core/packages/analytics-service/src/logging.ts +++ b/foundations/core/packages/analytics-service/src/logging.ts @@ -16,7 +16,7 @@ export class SplitLogger implements MeasureLogger { const rootDir = this.opts.root ?? 'logs' this.logger = winston.createLogger({ - level: 'info', + level: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info', exitOnError: false }) const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ @@ -99,6 +99,13 @@ export class SplitLogger implements MeasureLogger { this.logger.warn({ message, ...obj }) } + debug (message: string, obj?: Record): 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 { this.logger.info(operation, { time, ...params }) } diff --git a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts index 660147942f..4126458230 100644 --- a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts +++ b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts @@ -22,6 +22,7 @@ describe('telemetry', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } diff --git a/foundations/core/packages/measurements-otlp/src/telemetry.ts b/foundations/core/packages/measurements-otlp/src/telemetry.ts index c1770a01a3..a3df6a2193 100644 --- a/foundations/core/packages/measurements-otlp/src/telemetry.ts +++ b/foundations/core/packages/measurements-otlp/src/telemetry.ts @@ -11,6 +11,7 @@ import { updateMeasure, type FullParamsType, type MeasureLogger, + type MeasureLogLevel, type Metrics, type ParamsType, type WithOptions @@ -97,7 +98,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext { readonly logParams?: ParamsType, readonly otlpLogger?: Logger, - readonly meter?: MetricsContext + readonly meter?: MetricsContext, + readonly logLevel: MeasureLogLevel = 'info' ) { this.name = name this.params = params @@ -133,6 +135,7 @@ export class OpenTelemetryMetricsContext implements MeasureContext { logger?: MeasureLogger span?: WithOptions['span'] // By default true meta?: Record + logLevel?: MeasureLogLevel } ): MeasureContext { let _span: Span | undefined @@ -170,7 +173,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext { this, this.logParams, this.otlpLogger, - this.meter + this.meter, + opt?.logLevel ?? this.logLevel ) result.id = this.id result.contextData = this.contextData @@ -309,6 +313,23 @@ export class OpenTelemetryMetricsContext implements MeasureContext { this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) } + debug (message: string, args?: Record): 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 { this.done() } @@ -530,7 +551,8 @@ export function createOpenTelemetryMetricsContext ( ): MeasureContext { if (!initOpenTelemetrySDK(name, version ?? '')) { 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 @@ -542,6 +564,8 @@ export function createOpenTelemetryMetricsContext ( const meter = otelMetrics.getMeter(name, version) + const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info' + const ctx = new OpenTelemetryMetricsContext( name, tracer, @@ -554,7 +578,8 @@ export function createOpenTelemetryMetricsContext ( undefined, undefined, otlpLogger, - new MetricsContext(meter) + new MetricsContext(meter), + rootLogLevel ) return ctx } diff --git a/foundations/core/packages/measurements/src/__tests__/context.test.ts b/foundations/core/packages/measurements/src/__tests__/context.test.ts index d59d0a4945..818ecbd616 100644 --- a/foundations/core/packages/measurements/src/__tests__/context.test.ts +++ b/foundations/core/packages/measurements/src/__tests__/context.test.ts @@ -21,6 +21,7 @@ describe('context', () => { expect(typeof logger.info).toBe('function') expect(typeof logger.error).toBe('function') expect(typeof logger.warn).toBe('function') + expect(typeof logger.debug).toBe('function') expect(typeof logger.close).toBe('function') }) @@ -54,6 +55,16 @@ describe('context', () => { 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', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation() const logger = consoleLogger({}) @@ -145,6 +156,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } @@ -163,6 +175,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } @@ -181,6 +194,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), 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', () => { const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger) const params = ctx.getParams() @@ -440,6 +495,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } diff --git a/foundations/core/packages/measurements/src/context.ts b/foundations/core/packages/measurements/src/context.ts index 62dbb347cc..9a8688635c 100644 --- a/foundations/core/packages/measurements/src/context.ts +++ b/foundations/core/packages/measurements/src/context.ts @@ -6,6 +6,7 @@ import { type FullParamsType, type MeasureContext, type MeasureLogger, + type MeasureLogLevel, type Metrics, type ParamsType, type OperationLog, @@ -42,6 +43,14 @@ export const consoleLogger = (logParams: Record): MeasureLogger => warn: (msg, args) => { 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 () => {}, logOperation: (operation, time, params) => {} }) @@ -62,6 +71,8 @@ export class MeasureMetricsContext implements MeasureContext { metrics: Metrics id?: string + private readonly logLevel: MeasureLogLevel + st = platformNow() contextData: object = {} private done (value?: number, override?: boolean): void { @@ -75,12 +86,14 @@ export class MeasureMetricsContext implements MeasureContext { metrics: Metrics = newMetrics(), logger?: MeasureLogger, readonly parent?: MeasureContext, - readonly logParams?: ParamsType + readonly logParams?: ParamsType, + logLevel: MeasureLogLevel = 'info' ) { this.name = name this.params = params this.fullParams = fullParams this.metrics = metrics + this.logLevel = logLevel this.metrics.namedParams = this.metrics.namedParams ?? {} for (const [k, v] of Object.entries(params)) { if (this.metrics.namedParams[k] !== v) { @@ -94,7 +107,16 @@ export class MeasureMetricsContext implements MeasureContext { } 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.done(value, override) } @@ -106,6 +128,8 @@ export class MeasureMetricsContext implements MeasureContext { fullParams?: FullParamsType logger?: MeasureLogger span?: WithOptions['span'] // By default true + meta?: Record + logLevel?: MeasureLogLevel } ): MeasureContext { const result = new MeasureMetricsContext( @@ -115,7 +139,8 @@ export class MeasureMetricsContext implements MeasureContext { childMetrics(this.metrics, [name]), opt?.logger ?? this.logger, this, - this.logParams + this.logParams, + opt?.logLevel ?? this.logLevel ) result.id = this.id result.contextData = this.contextData @@ -187,6 +212,11 @@ export class MeasureMetricsContext implements MeasureContext { this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) } + debug (message: string, args?: Record): void { + if (this.logLevel !== 'debug') return + this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + end (): void { this.done() } @@ -202,8 +232,11 @@ export class NoMetricsContext implements MeasureContext { contextData: object = {} - constructor (logger?: MeasureLogger) { + private readonly logLevel: MeasureLogLevel + + constructor (logger?: MeasureLogger, logLevel: MeasureLogLevel = 'info') { this.logger = logger ?? consoleLogger({}) + this.logLevel = logLevel } measure (name: string, value: number, override?: boolean): void {} @@ -211,10 +244,15 @@ export class NoMetricsContext implements MeasureContext { newChild ( name: string, params: ParamsType, - fullParams?: FullParamsType | (() => FullParamsType), - logger?: MeasureLogger + opt?: { + fullParams?: FullParamsType | (() => FullParamsType) + logger?: MeasureLogger + span?: WithOptions['span'] + meta?: Record + logLevel?: MeasureLogLevel + } ): 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.contextData = this.contextData return result @@ -226,7 +264,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T | Promise, fullParams?: ParamsType | (() => FullParamsType) ): Promise { - 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) } @@ -240,7 +278,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T, fullParams?: ParamsType | (() => FullParamsType) ): T { - const c = this.newChild(name, params, fullParams, this.logger) + const c = this.newChild(name, params, { fullParams, logger: this.logger }) return op(c) } @@ -250,7 +288,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T | Promise, fullParams?: ParamsType ): Promise { - 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) } @@ -266,6 +304,11 @@ export class NoMetricsContext implements MeasureContext { this.logger.warn(message, { ...args }) } + debug (message: string, args?: Record): void { + if (this.logLevel !== 'debug') return + this.logger.debug(message, { ...args }) + } + end (): void {} getParams (): ParamsType { diff --git a/foundations/core/packages/measurements/src/types.ts b/foundations/core/packages/measurements/src/types.ts index d2d2e1366b..31b5dc80b1 100644 --- a/foundations/core/packages/measurements/src/types.ts +++ b/foundations/core/packages/measurements/src/types.ts @@ -49,6 +49,12 @@ export interface Metrics extends MetricsData { opLog?: Record } +/** + * Root log verbosity for {@link MeasureContext.debug} (child contexts inherit unless overridden in {@link MeasureContext.newChild}). + * @public + */ +export type MeasureLogLevel = 'info' | 'debug' + /** * @public */ @@ -58,6 +64,8 @@ export interface MeasureLogger { warn: (message: string, obj?: Record) => void + debug: (message: string, obj?: Record) => void + logOperation: (operation: string, time: number, params: ParamsType) => void childLogger?: (name: string, params: Record) => MeasureLogger @@ -94,6 +102,7 @@ export interface MeasureContext { logger?: MeasureLogger span?: WithOptions['span'] // By default true meta?: Record + logLevel?: MeasureLogLevel } ) => MeasureContext @@ -128,6 +137,7 @@ export interface MeasureContext { error: (message: string, obj?: Record) => void info: (message: string, obj?: Record) => void warn: (message: string, obj?: Record) => void + debug: (message: string, obj?: Record) => void // No-op unless this context was created with log level `debug`. // Mark current context as complete // If no value is passed, time difference will be used. diff --git a/foundations/server/packages/collaboration/src/__tests__/storage.test.ts b/foundations/server/packages/collaboration/src/__tests__/storage.test.ts index 4a27298913..dbde684129 100644 --- a/foundations/server/packages/collaboration/src/__tests__/storage.test.ts +++ b/foundations/server/packages/collaboration/src/__tests__/storage.test.ts @@ -150,6 +150,7 @@ const mockContext: MeasureContext = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), + debug: jest.fn(), with: jest.fn().mockImplementation((name, params, fn) => fn()), withSync: jest.fn().mockImplementation((name, params, fn) => fn()), measure: jest.fn(), diff --git a/foundations/server/packages/kafka/src/index.ts b/foundations/server/packages/kafka/src/index.ts index 5a88767930..74430845fe 100644 --- a/foundations/server/packages/kafka/src/index.ts +++ b/foundations/server/packages/kafka/src/index.ts @@ -259,9 +259,14 @@ class PlatformQueueConsumerImpl implements ConsumerHandle { 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({ 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) => { diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 3fe91ae1ee..887a29048f 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -279,17 +279,20 @@ export function createModel (builder: Builder): void { { hidden: false, generated: false, + allowedForAuthor: true, label: calendar.string.Reminder, 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, - allowedForAuthor: true, + onlyOwn: true, + defaultEnabled: true, templates: { - textTemplate: 'Reminder: {doc}', - htmlTemplate: 'Reminder: {doc}', - subjectTemplate: 'Reminder: {doc}' - }, - defaultEnabled: false + textTemplate: '{body}', + htmlTemplate: '

{body}

{link}

', + subjectTemplate: '{title}' + } }, calendar.ids.ReminderNotification ) @@ -300,6 +303,12 @@ export function createModel (builder: Builder): void { enabledTypes: [calendar.ids.ReminderNotification] }) + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.PushNotificationProvider, + ignoredTypes: [], + enabledTypes: [calendar.ids.ReminderNotification] + }) + builder.createDoc( activity.class.DocUpdateMessageViewlet, core.space.Model, diff --git a/models/calendar/src/plugin.ts b/models/calendar/src/plugin.ts index c5d02bcc95..b31114a8c7 100644 --- a/models/calendar/src/plugin.ts +++ b/models/calendar/src/plugin.ts @@ -55,7 +55,6 @@ export default mergeIds(calendarId, calendar, { string: { ApplicationLabelCalendar: '' as IntlString, Event: '' as IntlString, - Reminder: '' as IntlString, Shift: '' as IntlString, State: '' as IntlString, CreatedReminder: '' as IntlString, diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index 6d9b32dfa6..f85c49abd3 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -257,6 +257,7 @@ const calendarPlugin = plugin(calendarId, { PersonsLabel: '' as IntlString, EventNumber: '' as IntlString, Reminders: '' as IntlString, + Reminder: '' as IntlString, Today: '' as IntlString, Visibility: '' as IntlString, Public: '' as IntlString, diff --git a/plugins/time-resources/src/components/CreateToDoPopup.svelte b/plugins/time-resources/src/components/CreateToDoPopup.svelte index 036eb52e37..76f664893b 100644 --- a/plugins/time-resources/src/components/CreateToDoPopup.svelte +++ b/plugins/time-resources/src/components/CreateToDoPopup.svelte @@ -15,7 +15,7 @@ - +
+ + {#if slots.length > 0} +
+ +
+ {/if} +
diff --git a/pods/server/src/rpc.ts b/pods/server/src/rpc.ts index 4c147a4c09..125a22df36 100644 --- a/pods/server/src/rpc.ts +++ b/pods/server/src/rpc.ts @@ -37,7 +37,7 @@ import { promisify } from 'util' import { gzip } from 'zlib' import { retrieveJson } from './utils' -import { unknownError } from '@hcengineering/platform' +import platform, { PlatformError, unknownError } from '@hcengineering/platform' export const COMMUNICATION_DOMAIN = 'communication' as OperationDomain interface RPCClientInfo { @@ -270,15 +270,26 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur void withSession(req, res, 'tx', async (ctx, session, rateLimit) => { const tx: any = (await retrieveJson(req)) ?? {} - if (tx._class === core.class.TxDomainEvent) { - const domainTx = tx as TxDomainEvent - const { result } = await session.domainRequestRaw(ctx, domainTx.domain, { - event: domainTx.event - }) - await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit)) - } else { - const result = await session.txRaw(ctx, tx) - await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit)) + try { + if (tx._class === core.class.TxDomainEvent) { + const domainTx = tx as TxDomainEvent + const { result } = await session.domainRequestRaw(ctx, domainTx.domain, { + event: domainTx.event + }) + await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit)) + } else { + 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 } }) }) diff --git a/rush.json b/rush.json index 439e02a1b2..c04e21467c 100644 --- a/rush.json +++ b/rush.json @@ -2221,6 +2221,11 @@ "projectFolder": "services/notification/pod-notification", "shouldPublish": false }, + { + "packageName": "@hcengineering/pod-events-processor", + "projectFolder": "services/notification/pod-events-processor", + "shouldPublish": false + }, { "packageName": "@hcengineering/pod-telegram", "projectFolder": "services/telegram/pod-telegram", diff --git a/server-plugins/calendar-resources/src/index.ts b/server-plugins/calendar-resources/src/index.ts index 05a9b5d4f6..512a420391 100644 --- a/server-plugins/calendar-resources/src/index.ts +++ b/server-plugins/calendar-resources/src/index.ts @@ -44,6 +44,128 @@ import { QueueTopic, TriggerControl } from '@hcengineering/server-core' import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources' import { generateToken } from '@hcengineering/server-token' +const scheduledNotificationTopic = 'scheduledNotification' + +interface ScheduledNotificationMessage { + kind: 'eventReminder' + id: string + eventId: Ref + eventClass: Ref> + shiftMs: number + targetDate: number +} + +type TimeMachineMessage = + | { + type: 'schedule' + id: string + targetDate: number + topic: string + data: ScheduledNotificationMessage + } + | { + type: 'cancel' + id: string + } + +type TimeMachineScheduleMessage = Extract + +function eventReminderPrefix (eventId: Ref): string { + return `eventReminder_${eventId}_` +} + +function eventReminderTimerId (eventId: Ref, shiftMs: number): string { + // Stable so we can cancel by `${prefix}%`. + return `${eventReminderPrefix(eventId)}${shiftMs}` +} + +async function cancelEventReminders (control: TriggerControl, eventId: Ref): Promise { + try { + const queue = control.queue + if (queue === undefined) return + const producer = queue.getProducer(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): Promise { + 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(control.ctx, QueueTopic.TimeMachine) + await producer.send(control.ctx, control.workspace.uuid, msgs) + control.ctx.info('Queued event reminders', { + queueTopic: QueueTopic.TimeMachine, + eventId, + eventClass: event._class, + reminderCount: reminders.length, + enqueuedCount: msgs.length, + timerIds: msgs.map((msg) => msg.id), + targetDates: msgs.map((msg) => msg.targetDate) + }) + } catch (err) { + control.ctx.error('Failed to schedule Event reminders', { err, eventId }) + } +} + +export { scheduleEventReminders, cancelEventReminders } + /** * @public */ @@ -214,6 +336,10 @@ async function onEventUpdate (ctx: TxUpdateDoc, control: TriggerControl): void sendEventToService(event, 'update', control) } void putEventToQueue(control, 'update', event, ctx.modifiedBy, ops) + // Reschedule reminders if the event start time or the reminder offsets changed. + if (ops.date !== undefined || ops.reminders !== undefined) { + void scheduleEventReminders(control, ctx.objectId) + } if (event.access !== 'owner') return [] const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId }) const res: Tx[] = [] @@ -363,6 +489,8 @@ async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): void sendEventToService(event, 'create', control) } void putEventToQueue(control, 'create', event, ctx.modifiedBy) + // Schedule reminders for any newly created event (including WorkSlots, since those are Events too). + void scheduleEventReminders(control, event._id) if (event.access !== 'owner') return [] const res: Tx[] = [] const { _class, space, ...attr } = event @@ -398,6 +526,7 @@ async function onEventCreate (ctx: TxCreateDoc, control: TriggerControl): async function onRemoveEvent (ctx: TxRemoveDoc, control: TriggerControl): Promise { const removed = control.removedMap.get(ctx.objectId) as Event const res: Tx[] = [] + void cancelEventReminders(control, ctx.objectId) if (removed !== undefined) { if (ctx.modifiedBy !== core.account.System && removed.access === 'owner') { void sendEventToService(removed, 'delete', control) diff --git a/server-plugins/calendar-resources/src/reminderScheduling.test.ts b/server-plugins/calendar-resources/src/reminderScheduling.test.ts new file mode 100644 index 0000000000..89de7ea065 --- /dev/null +++ b/server-plugins/calendar-resources/src/reminderScheduling.test.ts @@ -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 + +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 = {}): { 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 + ] + } + 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 + ] + } + 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 + ] + } + 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 + ] + } + 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() + }) +}) diff --git a/server-plugins/notification-resources/src/push.ts b/server-plugins/notification-resources/src/push.ts index 7c52242733..a1d5890d1d 100644 --- a/server-plugins/notification-resources/src/push.ts +++ b/server-plugins/notification-resources/src/push.ts @@ -15,6 +15,7 @@ import serverCore, { TriggerControl } from '@hcengineering/server-core' import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification' +import type { ReceiverInfo } from '@hcengineering/server-notification' import { AccountUuid, Class, @@ -49,6 +50,7 @@ import contact, { } from '@hcengineering/contact' import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index' import { getPerson } from '@hcengineering/server-contact' +import { getAllowedProviders, getNotificationProviderControl, getReceiversInfo } from './utils' async function createPushFromInbox ( control: TriggerControl, @@ -235,25 +237,51 @@ export async function PushNotificationsHandler ( ): Promise { const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map() - const all: InboxNotification[] = txes - .map((tx) => TxProcessor.createDoc2Doc(tx)) - .filter( - (it) => - availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined - ) + const all: InboxNotification[] = txes.map((tx) => TxProcessor.createDoc2Doc(tx)) + + // First pass: use cache if present. + const pushEnabled: InboxNotification[] = all.filter( + (it) => + availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined + ) + + // Fallback: if cache doesn't have the provider info (e.g. scheduled notifications created outside tx-trigger paths), + // compute allowed providers from notification type + user settings. + if (pushEnabled.length < all.length) { + const notificationControl = await getNotificationProviderControl(control.ctx, control) + const receivers: ReceiverInfo[] = await getReceiversInfo(control.ctx, [...new Set(all.map((n) => n.user))], control) + const receiverByAccount = new Map(receivers.map((r) => [r.account, r])) + + for (const n of all) { + if (availableProviders.get(n._id) !== undefined) continue + if (pushEnabled.includes(n)) continue + + const type = (n.types ?? [])[0] + if (type === undefined) continue + + const notificationType = control.modelDb.getObject(type) + const receiver = receiverByAccount.get(n.user) + if (receiver === undefined) continue + + const allowedProviders = getAllowedProviders(control, receiver.socialIds, notificationType, notificationControl) + if (allowedProviders.includes(notification.providers.PushNotificationProvider)) { + pushEnabled.push(n) + } + } + } - if (all.length === 0) { + if (pushEnabled.length === 0) { return [] } - const receivers = new Set(all.map((it) => it.user)) + const receivers = new Set(pushEnabled.map((it) => it.user)) const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) => receivers.has(it.user) ) const res: Tx[] = [] - for (const inboxNotification of all) { + for (const inboxNotification of pushEnabled) { const { user } = inboxNotification const userSubscriptions = subscriptions.filter((it) => it.user === user) diff --git a/services/gmail/pod-gmail/src/__tests__/attachments.test.ts b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts index d7b7d70484..a255b836b9 100644 --- a/services/gmail/pod-gmail/src/__tests__/attachments.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/attachments.test.ts @@ -32,6 +32,7 @@ describe('AttachmentHandler', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), logOperation: jest.fn(), childLogger: jest.fn(), close: jest.fn() @@ -40,6 +41,7 @@ describe('AttachmentHandler', () => { error: jest.fn(), info: jest.fn(), warn: jest.fn(), + debug: jest.fn(), end: jest.fn(), getParams: jest.fn() } diff --git a/services/notification/pod-events-processor/.eslintrc.js b/services/notification/pod-events-processor/.eslintrc.js new file mode 100644 index 0000000000..6ab3cb53db --- /dev/null +++ b/services/notification/pod-events-processor/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} + diff --git a/services/notification/pod-events-processor/Dockerfile b/services/notification/pod-events-processor/Dockerfile new file mode 100644 index 0000000000..4f2f62f5d6 --- /dev/null +++ b/services/notification/pod-events-processor/Dockerfile @@ -0,0 +1,6 @@ +FROM hardcoreeng/base-slim:v20250916 +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ + +CMD [ "node", "bundle.js" ] diff --git a/services/notification/pod-events-processor/jest.config.js b/services/notification/pod-events-processor/jest.config.js new file mode 100644 index 0000000000..7929244e79 --- /dev/null +++ b/services/notification/pod-events-processor/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ['./src'], + coverageReporters: ['text-summary', 'html'] +} + diff --git a/services/notification/pod-events-processor/package.json b/services/notification/pod-events-processor/package.json new file mode 100644 index 0000000000..61dcd08b03 --- /dev/null +++ b/services/notification/pod-events-processor/package.json @@ -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" + } +} + diff --git a/services/notification/pod-events-processor/src/__tests__/clientCache.test.ts b/services/notification/pod-events-processor/src/__tests__/clientCache.test.ts new file mode 100644 index 0000000000..00da1df7d3 --- /dev/null +++ b/services/notification/pod-events-processor/src/__tests__/clientCache.test.ts @@ -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() + }) +}) diff --git a/services/notification/pod-events-processor/src/__tests__/worker.test.ts b/services/notification/pod-events-processor/src/__tests__/worker.test.ts new file mode 100644 index 0000000000..79a32c7fd6 --- /dev/null +++ b/services/notification/pod-events-processor/src/__tests__/worker.test.ts @@ -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) + }) + }) +}) diff --git a/services/notification/pod-events-processor/src/client.ts b/services/notification/pod-events-processor/src/client.ts new file mode 100644 index 0000000000..871a85caf4 --- /dev/null +++ b/services/notification/pod-events-processor/src/client.ts @@ -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 { + 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) + } +} diff --git a/services/notification/pod-events-processor/src/clientCache.ts b/services/notification/pod-events-processor/src/clientCache.ts new file mode 100644 index 0000000000..2f8fda66cc --- /dev/null +++ b/services/notification/pod-events-processor/src/clientCache.ts @@ -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() +const inFlightClientCreations = new Map>() + +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 | undefined { + return inFlightClientCreations.get(key) +} + +export function setInFlightClientCreation (key: string, creation: Promise): void { + inFlightClientCreations.set(key, creation) +} + +export function clearInFlightClientCreation (key: string): void { + inFlightClientCreations.delete(key) +} + +export function clearClientCachesForTests (): void { + clientCache.clear() + inFlightClientCreations.clear() +} diff --git a/services/notification/pod-events-processor/src/config.ts b/services/notification/pod-events-processor/src/config.ts new file mode 100644 index 0000000000..cea0aed7fe --- /dev/null +++ b/services/notification/pod-events-processor/src/config.ts @@ -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 diff --git a/services/notification/pod-events-processor/src/index.ts b/services/notification/pod-events-processor/src/index.ts new file mode 100644 index 0000000000..c52d2da210 --- /dev/null +++ b/services/notification/pod-events-processor/src/index.ts @@ -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 { + 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( + 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) +}) diff --git a/services/notification/pod-events-processor/src/types.ts b/services/notification/pod-events-processor/src/types.ts new file mode 100644 index 0000000000..f58eb5b5d4 --- /dev/null +++ b/services/notification/pod-events-processor/src/types.ts @@ -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 + eventClass: Ref> + shiftMs: number + targetDate: number +} diff --git a/services/notification/pod-events-processor/src/worker.ts b/services/notification/pod-events-processor/src/worker.ts new file mode 100644 index 0000000000..4658b96925 --- /dev/null +++ b/services/notification/pod-events-processor/src/worker.ts @@ -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 + attachedToClass?: Ref> + user?: PersonId + title?: string +} + +export function buildReminderNotificationId (timerId: string): Ref { + return `eventReminderInbox:${timerId}` as Ref +} + +interface ReminderTarget { + objectId: Ref + objectClass: Ref> + objectSpace: Ref + titleText: string + receiverAccount: AccountUuid + receiverSpace: Ref +} + +export async function handleScheduledNotification ( + ctx: MeasureContext, + workspaceUuid: WorkspaceUuid, + msg: ScheduledNotificationMessage, + control: ConsumerControl +): Promise { + 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 | 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 { + 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>, { + _id: event.attachedTo as Ref + })) 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 }, + { 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 + } +} diff --git a/services/notification/pod-events-processor/tsconfig.json b/services/notification/pod-events-processor/tsconfig.json new file mode 100644 index 0000000000..41f3dcae2c --- /dev/null +++ b/services/notification/pod-events-processor/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} + diff --git a/services/worker/README.md b/services/worker/README.md index 45dc4df86b..102a09f112 100644 --- a/services/worker/README.md +++ b/services/worker/README.md @@ -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. | | `POLL_INTERVAL` | `5000` | Polling interval for expired events in milliseconds. | | `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 diff --git a/services/worker/src/config.ts b/services/worker/src/config.ts index 3c6ae330a2..3a76a81c51 100644 --- a/services/worker/src/config.ts +++ b/services/worker/src/config.ts @@ -21,13 +21,15 @@ export interface Config { PollInterval: number QueueRegion: string QueueConfig: string + LogLevel: 'info' | 'debug' } const config: Config = { DbUrl: process.env.DB_URL ?? 'postgres://localhost:5432/huly', PollInterval: process.env.POLL_INTERVAL != null ? Number(process.env.POLL_INTERVAL) : 20000, 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 diff --git a/services/worker/src/worker.ts b/services/worker/src/worker.ts index 5d5c339d0b..50289894cb 100644 --- a/services/worker/src/worker.ts +++ b/services/worker/src/worker.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import { MeasureMetricsContext } from '@hcengineering/core' +import { MeasureMetricsContext, newMetrics } from '@hcengineering/core' import { getPlatformQueue } from '@hcengineering/kafka' import { QueueTopic } from '@hcengineering/server-core' import { TimeMachineMessage } from '@hcengineering/server-process' @@ -25,13 +25,29 @@ export async function runWorker (): Promise { const SERVICE_NAME = 'time-machine' 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) // 1. Kafka Consumer for commands queue.createConsumer(ctx, QueueTopic.TimeMachine, SERVICE_NAME, async (ctx, msg) => { const { type, id, targetDate, topic, data } = msg.value 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({ id, workspace: msg.workspace, @@ -40,6 +56,7 @@ export async function runWorker (): Promise { data }) } else if (type === 'cancel') { + ctx.debug('TimeMachine consume cancel', { workspace: msg.workspace, idPattern: id }) await db.removeEvents(msg.workspace, id) } }) @@ -49,13 +66,24 @@ export async function runWorker (): Promise { try { const expiredEvents = await db.getExpiredEvents() if (expiredEvents.length > 0) { + ctx.debug('TimeMachine poll expired', { + count: expiredEvents.length, + ids: expiredEvents.map((e) => e.id) + }) 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 db.deleteEvents(expiredEvents) } } catch (err) { - ctx.error('Error in Time Machine polling loop:') + ctx.error('Error in Time Machine polling loop', { err }) } finally { setTimeout(() => { void poll() From 703f6d41390cad5829efb5a6d8472180d622c0a5 Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Wed, 8 Jul 2026 16:18:31 +0700 Subject: [PATCH 07/15] Fix export visibility (#10960) Signed-off-by: Artyom Savchenko --- models/setting/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index c063d87aa4..52c15d8ec7 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -419,7 +419,7 @@ export function createModel (builder: Builder): void { component: exportPlugin.component.ExportSettings, group: 'settings-editor', feature: 'export', - role: AccountRole.User, + role: AccountRole.Owner, order: 4800 }, setting.ids.Export From e884e795a45ae67376d904adee921b06f86310e2 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 9 Jul 2026 09:03:34 +0700 Subject: [PATCH 08/15] fix: encode filename in datalake url (#10963) Signed-off-by: Alexander Onnikov --- .../src/__tests__/datalake-storage.test.ts | 14 ++++++++++++-- .../src/__tests__/front-storage.test.ts | 14 ++++++++++++-- .../storage-client/src/client/datalake.ts | 8 +++++++- .../storage-client/src/client/front.ts | 6 +++++- .../storage-client/src/client/utils.ts | 18 ++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 foundations/core/packages/storage-client/src/client/utils.ts diff --git a/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts index fcdfa9099c..13c96e4863 100644 --- a/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts +++ b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts @@ -57,14 +57,24 @@ describe('DatalakeStorage', () => { expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}`) }) - it('should handle special characters in parameters', () => { + it('should encode special characters in parameters', () => { const workspace = 'test workspace' const file = 'file 123' const filename = 'my document.pdf' const url = storage.getFileUrl(workspace, file, filename) - expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/${filename}`) + expect(url).toBe(`${baseUrl}/blob/test%20workspace/file%20123/my%20document.pdf`) + }) + + it('should encode slash in filename as a single path segment', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'folder/document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/folder%2Fdocument.pdf`) }) it('should handle base URL with trailing slash', () => { diff --git a/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts index 5c8e80c6ef..fe8f127f98 100644 --- a/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts +++ b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts @@ -55,14 +55,24 @@ describe('FrontStorage', () => { expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) }) - it('should handle special characters in workspace and file names', () => { + it('should encode special characters in workspace, file, and filename', () => { const workspace = 'test workspace' const file = 'file 123' const filename = 'my document.pdf' const url = storage.getFileUrl(workspace, file, filename) - expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) + expect(url).toBe(`${baseUrl}/test%20workspace/my%20document.pdf?file=file%20123&workspace=test%20workspace`) + }) + + it('should encode slash in filename as a single path segment', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'folder/document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/${workspace}/folder%2Fdocument.pdf?file=${file}&workspace=${workspace}`) }) it('should handle base URL with trailing slash', () => { diff --git a/foundations/core/packages/storage-client/src/client/datalake.ts b/foundations/core/packages/storage-client/src/client/datalake.ts index ad1dc1f7aa..97ef5884ae 100644 --- a/foundations/core/packages/storage-client/src/client/datalake.ts +++ b/foundations/core/packages/storage-client/src/client/datalake.ts @@ -16,6 +16,7 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadMultipart, uploadXhr } from '../upload' +import { encodePathSegment } from './utils' const getPathname = (url: string): string => { const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' @@ -27,7 +28,12 @@ export class DatalakeStorage implements FileStorage { constructor (private readonly baseUrl: string) {} getFileUrl (workspace: string, file: string, filename?: string): string { - const path = filename !== undefined ? `/blob/${workspace}/${file}/${filename}` : `/blob/${workspace}/${file}` + const encodedWorkspace = encodePathSegment(workspace) + const encodedFile = encodePathSegment(file) + const path = + filename !== undefined + ? `/blob/${encodedWorkspace}/${encodedFile}/${encodePathSegment(filename)}` + : `/blob/${encodedWorkspace}/${encodedFile}` return concatLink(this.baseUrl, path) } diff --git a/foundations/core/packages/storage-client/src/client/front.ts b/foundations/core/packages/storage-client/src/client/front.ts index 54fa4cd0b9..7dfa5d6896 100644 --- a/foundations/core/packages/storage-client/src/client/front.ts +++ b/foundations/core/packages/storage-client/src/client/front.ts @@ -16,6 +16,7 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadXhr } from '../upload' +import { encodePathSegment } from './utils' const getPathname = (url: string): string => { const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' @@ -27,7 +28,10 @@ export class FrontStorage implements FileStorage { constructor (private readonly baseUrl: string) {} getFileUrl (workspace: string, file: string, filename?: string): string { - const path = `/${workspace}/${filename ?? file}?file=${file}&workspace=${workspace}` + const encodedWorkspace = encodePathSegment(workspace) + const encodedFile = encodePathSegment(file) + const encodedFilename = encodePathSegment(filename ?? file) + const path = `/${encodedWorkspace}/${encodedFilename}?file=${encodedFile}&workspace=${encodedWorkspace}` return concatLink(this.baseUrl, path) } diff --git a/foundations/core/packages/storage-client/src/client/utils.ts b/foundations/core/packages/storage-client/src/client/utils.ts new file mode 100644 index 0000000000..e6e41b9a6b --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/utils.ts @@ -0,0 +1,18 @@ +// +// 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. +// + +export function encodePathSegment (value: string): string { + return encodeURIComponent(value) +} From 4891c65b3d0398b4a3ef318a0590e2692052b3a0 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Thu, 9 Jul 2026 09:04:01 +0700 Subject: [PATCH 09/15] fix: do not show notifications in print mode (#10962) Signed-off-by: Alexander Onnikov --- packages/ui/src/components/notifications/Notifications.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/notifications/Notifications.svelte b/packages/ui/src/components/notifications/Notifications.svelte index 2252d7dcbd..57d4025d74 100644 --- a/packages/ui/src/components/notifications/Notifications.svelte +++ b/packages/ui/src/components/notifications/Notifications.svelte @@ -14,7 +14,7 @@ -
+
{#each Object.entries(positionByClassName) as [className, position]}
{#each $store.slice(0, maxVisibleNotifications) as notification (notification.id)} From abe0cb96259530eeebbe0e9625668a3ada8a4294 Mon Sep 17 00:00:00 2001 From: Michael Uray <25169478+MichaelUray@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:08:17 +0200 Subject: [PATCH 10/15] feat(tracker): Gantt scheduling schema (startDate + IssueRelation) + version bump (#10851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tracker): add Gantt scheduling schema (startDate + IssueRelation) Schema-only foundation for the upcoming Gantt-chart view in tracker. No UI in this PR. Changes: - Issue.startDate: Timestamp | null (interface + IssueDraft + @Prop with @Index) - Milestone.startDate: Timestamp | null (interface + @Prop, reusing the existing tracker.string.StartDate IntlString) - New DependencyKind type ('finish-to-start' | 'start-to-start' | 'finish-to-finish' | 'start-to-finish') - New IssueRelation AttachedDoc class with kind: DependencyKind, signed lag: number — registered in models/tracker via TIssueRelation - 7 new IntlString keys: IssueStartDate, GanttDependency, GanttDependency{FinishToStart,StartToStart,FinishToFinish,StartToFinish}, GanttLag — all 13 locales updated - Cross-plugin literal updates in importer + github sync to satisfy the new required Issue.startDate / Milestone.startDate fields: - packages/importer/src/importer/importer.ts: AttachedData literal - services/github/pod-github/src/sync/issueBase.ts: 'startDate' added to GithubIssueData Omit list (github sync does not own scheduling) - services/github/pod-github/src/sync/issues.ts + pullrequests.ts: AttachedData literals Out of scope (deferred to follow-up PRs): - UI for Gantt view, drag/resize, dependency editor, critical path - blockedBy → IssueRelation migration (ships atomically with the writer redirect in the dependency-UI PR) - LinkIssues permission (tracker uses forbid-style permissions; needs maintainer discussion) - Activity-feed wiring for IssueRelation (needs a producer to test against) - IssueTemplate.startDate (template propagation semantics undecided) Signed-off-by: Michael Uray * test(model-tracker): add migrateAddStartDate jest tests 3 tests covering migrateAddStartDate: - writes startDate=null to Issues in DOMAIN_TASK with the right filter - writes startDate=null to Milestones in DOMAIN_TRACKER with the right filter - issues exactly two update calls (one per class) Follows the MigrationClient mock pattern from models/chat/src/__tests__/migration.test.ts. Signed-off-by: Michael Uray * feat(model-tracker): add migrateAddStartDate + wire into trackerOperation Backfills startDate=null on existing Issues (DOMAIN_TASK) and Milestones (DOMAIN_TRACKER) so the new schema field has a defined value on every pre-existing document. Idempotent via the standard tryMigrate state-key mechanism (state: 'gantt-add-startdate'). Verified domain choices against existing migration helpers: - migrateIdentifiers / passIdentifierToParentInfo use DOMAIN_TASK for Issues (lines 145, 161 in this file). - TMilestone @Model decorator confirms DOMAIN_TRACKER for Milestones (models/tracker/src/types.ts:372). Signed-off-by: Michael Uray * feat(tracker): expose Issue.startDate / Milestone.startDate in UI; tighten typing UI changes (so the new schema fields are actually editable, in chronological order Start → Due/Target): - New StartDateEditor.svelte (mirrors DueDateEditor.svelte for startDate) - ControlPanel: render Start Date row above Due Date row in the issue side panel; both always-visible (no `!== null` guard) so users can set them on issues that don't have a date yet - NewMilestone form: Start Date input above Target Date input - Milestone list view: Start Date column before Target Date column - TIssueRelation: tighten interface to `extends AttachedDoc` so attachedTo + collection are statically typed. The model class re-declares `collection: 'relations'` to match the narrower base. - Drop 4 unused Dependency-kind IntlString keys (FinishToFinish, FinishToStart, StartToFinish, StartToStart) — they had no consumer in PR 1; will be re-introduced in PR 4 (dependency editor). - Simplify migration.ts comments — drop ageing line-references. Signed-off-by: Michael Uray * fix(tracker): set explicit @Prop ranks for Milestone date fields The DocAttributeBar side panel sorts attributes by attr.rank ?? toRank(_id) (see plugins/view-resources/src/components/ClassAttributeBar.svelte:42-47), so without explicit ranks the visible order on a Milestone was hash-based (startDate before Status, breaking the chronological flow the user expects). Set ranks so the side panel renders Status → Start date → Target date. Comments and attachments stay where they are (they're collections, filtered out of the attribute panel by categorizeFields). Issues are unaffected — the Issue side panel is the custom ControlPanel.svelte which renders Start date / Due date in explicit slots (see PR 1's UI commit). Signed-off-by: Michael Uray * fix(tracker-resources): EditMilestone renders Status/Start/Target in body in chronological order The right-side DocAttributeBar sorts attributes by attr.rank ?? toRank(_id), giving startDate before status (toRank('startDate') < toRank('status') lexicographically). Setting an explicit rank via @Prop's third arg did not propagate through the workspace upgrade for existing Attribute documents in the model TX log — the rank made it into the bundled txes but the existing Attribute creation TXes are not replaced on upgrade-workspace. Pivot: render Status, Start date, Target date in the EditMilestone body in explicit chronological order, and add 'status', 'startDate', 'targetDate' to ignoreKeys so they don't appear duplicated in the side panel. This mirrors how Issue's ControlPanel.svelte handles its date fields. Reverts the no-op @Prop rank attempt. Signed-off-by: Michael Uray * fix(fulltext): bump model version to 0.7.423 to match deployed workspaces The fulltext-pod's compiled model version (baked into bundle/model.json via common/scripts/version.txt at build time) lags whenever the workspaces have been migrated to a newer patch but the pod was not rebuilt. In that state the indexer rejects every incoming Tx with a `wrong version` warning, new issues silently fail to land in Elasticsearch, and search returns empty results for any document created after the migration. Bumping `version.txt` aligns the compiled model with the workspaces. All future builds (front, transactor, workspace, tool, fulltext) will emit 0.7.423, the indexer accepts the Tx stream again, and the deferred backlog gets consumed automatically — no manual reindex needed. This commit is the build-side companion to the schema migration in this same PR. Without it the fulltext-pod cannot consume the migrated workspace's Tx events. Signed-off-by: Michael Uray * chore: apply rush format after develop merge Resolves the failing formatting check requested by @ArtyomSavchenko in review of #10851 after the develop branch merge. Affects three files in our PR scope: - models/tracker/src/migration.ts: collapse short multi-line client.update call - plugins/tracker/src/index.ts: inline DependencyKind union + IssueRelation comment - plugins/tracker-resources/src/components/milestones/EditMilestone.svelte: reformat inline arrow handlers, move QueryIssuesList block ahead of diff --git a/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte b/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte index 6139262bc3..a74c93fa50 100644 --- a/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte +++ b/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte @@ -34,6 +34,7 @@ status: MilestoneStatus.Planned, comments: 0, attachments: 0, + startDate: null, targetDate: Date.now() + 14 * 24 * 60 * 60 * 1000 } @@ -76,6 +77,14 @@ /> + milestone?: Ref | null @@ -236,6 +251,7 @@ export interface IssueDraft { assignee: Ref | null component: Ref | null space: Ref + startDate: Timestamp | null dueDate: Timestamp | null milestone?: Ref | null @@ -323,6 +339,21 @@ export interface IssueParentInfo { space: Ref } +/** + * Typed dependency between two Issues, used by the Gantt view to compute + * cascade scheduling and critical path. + * + * Persisted as an AttachedDoc collection 'relations' on the source Issue. + * + * @public + */ +export interface IssueRelation extends AttachedDoc { + target: Ref // successor + kind: DependencyKind + /** Lag in schedule days; can be negative (overlap). */ + lag: number +} + /** * @public */ @@ -366,6 +397,7 @@ const pluginState = plugin(trackerId, { class: { Project: '' as Ref>, Issue: '' as Ref>, + IssueRelation: '' as Ref>, IssueTemplate: '' as Ref>, Component: '' as Ref>, IssueStatus: '' as Ref>, @@ -520,6 +552,9 @@ const pluginState = plugin(trackerId, { Project: '' as IntlString, RelatedIssues: '' as IntlString, Issue: '' as IntlString, + IssueStartDate: '' as IntlString, + GanttDependency: '' as IntlString, + GanttLag: '' as IntlString, NewProject: '' as IntlString, UnsetParentIssue: '' as IntlString, ForbidCreateProjectPermission: '' as IntlString, diff --git a/services/github/pod-github/src/sync/issueBase.ts b/services/github/pod-github/src/sync/issueBase.ts index bf4ce7535f..ff181599b6 100644 --- a/services/github/pod-github/src/sync/issueBase.ts +++ b/services/github/pod-github/src/sync/issueBase.ts @@ -69,6 +69,7 @@ WithMarkup, | 'reports' | 'childInfo' | 'dueDate' +| 'startDate' | 'kind' | 'reviews' | 'reviewThreads' diff --git a/services/github/pod-github/src/sync/issues.ts b/services/github/pod-github/src/sync/issues.ts index d4121953a8..07d7b2cea9 100644 --- a/services/github/pod-github/src/sync/issues.ts +++ b/services/github/pod-github/src/sync/issues.ts @@ -868,6 +868,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan rank: calcRank(lastOne, undefined), comments: 0, subIssues: 0, + startDate: null, dueDate: null, parents: [], reportedTime: 0, diff --git a/services/github/pod-github/src/sync/pullrequests.ts b/services/github/pod-github/src/sync/pullrequests.ts index 0d57422205..ebc6e9054d 100644 --- a/services/github/pod-github/src/sync/pullrequests.ts +++ b/services/github/pod-github/src/sync/pullrequests.ts @@ -1169,6 +1169,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS rank: calcRank(lastOne, undefined), comments: 0, subIssues: 0, + startDate: null, dueDate: null, parents: [], reportedTime: 0, diff --git a/tests/sanity/tests/model/tracker/issues-details-page.ts b/tests/sanity/tests/model/tracker/issues-details-page.ts index 53fb1f555f..4e2f1ca7ae 100644 --- a/tests/sanity/tests/model/tracker/issues-details-page.ts +++ b/tests/sanity/tests/model/tracker/issues-details-page.ts @@ -29,7 +29,11 @@ export class IssuesDetailsPage extends CommonTrackerPage { readonly textEstimation = (): Locator => this.page.locator('//span[text()="Estimation"]/following-sibling::div[1]/button/span') - readonly buttonEstimation = (): Locator => this.page.locator('(//span[text()="Estimation"]/../div/button)[3]') + // ControlPanel now renders Start Date + Due Date rows unconditionally + // (Gantt schema PR — Issue.startDate). Both editors emit a `
{:else}