From d9d10a1d7e1c0f26b564ebca826162f504f48535 Mon Sep 17 00:00:00 2001 From: Clay Risser Date: Thu, 13 Aug 2026 07:38:44 +0100 Subject: [PATCH] fix(notification): stamp matched types and provider cache in getCommonNotificationTxes getCommonNotificationTxes passes a literal [] where the matched notification types belong (index.ts:131) and never writes AvailableProvidersCache, so every notification born through it is types: [] with no cache entry. Its sibling getNotificationTxes does both correctly, four hundred lines away in the same file; this lifts the two blocks verbatim so the paths agree. types: [] defeats the tree's own fallbacks. push.ts:248 and gmail-resources/src/index.ts:324 both compute allowed providers from (n.types ?? [])[0] when the cache misses, and skip when it is undefined -- gmail logging "NotificationsHandler: skipping notification without type". Three callers are affected: time-resources (ToDo), and activity-resources for reactions and mentions. The reaction and mention callers are the tell: both resolve a NotificationType into a local, put it in notifyResult, and the callee drops it. Tests: server-plugins/notification-resources had jest --passWithNoTests and no suites; commonNotificationTypes.test.ts is the first. Three cases were red before this change (the type on the document, the cache entry, and the cache entry under the ignored-provider shape). Co-authored-by: Cursor Signed-off-by: Clay Risser Co-authored-by: Cursor --- .../__tests__/commonNotificationTypes.test.ts | 348 ++++++++++++++++++ .../notification-resources/src/index.ts | 14 +- 2 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 server-plugins/notification-resources/src/__tests__/commonNotificationTypes.test.ts diff --git a/server-plugins/notification-resources/src/__tests__/commonNotificationTypes.test.ts b/server-plugins/notification-resources/src/__tests__/commonNotificationTypes.test.ts new file mode 100644 index 00000000000..1d0fd104c79 --- /dev/null +++ b/server-plugins/notification-resources/src/__tests__/commonNotificationTypes.test.ts @@ -0,0 +1,348 @@ +// +// 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 Employee, type PersonSpace } from '@hcengineering/contact' +import core, { + TxFactory, + type AccountUuid, + type Class, + type Doc, + type PersonId, + type Ref, + type Space, + type Tx, + type TxCreateDoc, + type TxCUD +} from '@hcengineering/core' +import notification, { + type CommonInboxNotification, + type DocNotifyContext, + type InboxNotification, + type NotificationProvider, + type NotificationType +} from '@hcengineering/notification' +import { type TriggerControl } from '@hcengineering/server-core' +import { type ReceiverInfo, type SenderInfo } from '@hcengineering/server-notification' + +import { getCommonNotificationTxes, pushInboxNotifications } from '../index' +import { AvailableProvidersCacheKey, type AvailableProvidersCache, type NotifyResult } from '../types' +import { NotificationProviderControl, isShouldNotifyTx } from '../utils' + +// Wire ids, not imported symbols: this package depends on neither +// @hcengineering/time nor the fork's model-matrix, and adding a package +// dependency to a test is a worse trade than a literal. Same convention the +// fork's `_huly/models/matrix/src/notificationTypes.ts` documents. +const TODO_CREATED = 'time:ids:ToDoCreated' as Ref +const PROJECT_TODO = 'time:class:ProjectToDo' as Ref> +const DM_NOTIFICATION = 'chunter:ids:DMNotification' as Ref +const MATRIX_PROVIDER = 'matrix:providers:MatrixNotificationProvider' as Ref +const MATRIX_NEW_MESSAGE = 'matrix:ids:NewMessageNotification' as Ref + +const RECEIVER: ReceiverInfo = { + account: 'account-clay' as AccountUuid, + employee: 'employee-clay' as Ref, + role: 'USER', + socialIds: ['social-clay' as PersonId], + space: 'personspace-clay' as Ref +} + +const SENDER: SenderInfo = { socialId: 'social-bot' as PersonId } + +const OBJECT_ID = 'todo-parent-issue' as Ref +const OBJECT_CLASS = 'tracker:class:Issue' as Ref> +const OBJECT_SPACE = 'space-project' as Ref + +/** A pre-existing context, so the writer never takes the create-context branch. */ +const CONTEXT: DocNotifyContext = { + _id: 'ctx-1' as Ref, + _class: notification.class.DocNotifyContext, + space: RECEIVER.space, + objectId: OBJECT_ID, + objectClass: OBJECT_CLASS, + objectSpace: OBJECT_SPACE, + user: RECEIVER.account, + isPinned: false, + hidden: false, + modifiedOn: 1, + modifiedBy: SENDER.socialId +} as unknown as DocNotifyContext + +interface Harness { + control: TriggerControl + contextCache: Map +} + +function harness (): Harness { + const contextCache = new Map() + const control = { + ctx: { contextData: { broadcast: { txes: [] } } }, + txes: [], + txFactory: new TxFactory(SENDER.socialId), + contextCache, + cache: new Map(), + removedMap: new Map(), + findAll: async () => [CONTEXT], + apply: async () => {} + } as unknown as TriggerControl + + return { control, contextCache } +} + +const measure = { contextData: { broadcast: { txes: [] } } } as any + +async function commonTxes (control: TriggerControl, notifyResult: NotifyResult): Promise { + return await getCommonNotificationTxes( + measure, + control, + { _id: OBJECT_ID, _class: OBJECT_CLASS, space: OBJECT_SPACE } as unknown as Doc, + { header: 'time:string:ToDo' as any }, + RECEIVER, + SENDER, + OBJECT_ID, + OBJECT_CLASS, + OBJECT_SPACE, + 1, + notifyResult, + notification.class.CommonInboxNotification + ) +} + +function created (txes: Tx[]): TxCreateDoc { + const tx = txes.find( + (it) => + it._class === core.class.TxCreateDoc && + (it as TxCreateDoc).objectClass === notification.class.CommonInboxNotification + ) + expect(tx).toBeDefined() + return tx as TxCreateDoc +} + +function cacheOf (h: Harness): AvailableProvidersCache { + return h.contextCache.get(AvailableProvidersCacheKey) ?? new Map() +} + +describe('getCommonNotificationTxes carries the matched types', () => { + it('stamps the ToDo type onto the notification it creates', async () => { + const h = harness() + const notifyResult: NotifyResult = new Map([ + [notification.providers.InboxNotificationProvider, [{ _id: TODO_CREATED } as NotificationType]], + [MATRIX_PROVIDER, [{ _id: TODO_CREATED } as NotificationType]] + ]) + + const txes = await commonTxes(h.control, notifyResult) + + expect(created(txes).attributes.types).toEqual([TODO_CREATED]) + }) + + it('publishes the allowed providers so the messenger triggers can see the notification', async () => { + const h = harness() + const notifyResult: NotifyResult = new Map([ + [notification.providers.InboxNotificationProvider, [{ _id: TODO_CREATED } as NotificationType]], + [MATRIX_PROVIDER, [{ _id: TODO_CREATED } as NotificationType]] + ]) + + const txes = await commonTxes(h.control, notifyResult) + const notificationTx = created(txes) + + // Consumers look the entry up by the notification's own _id, which is the + // create tx's objectId — the same key `getNotificationTxes` writes. + expect(cacheOf(h).get(notificationTx.objectId as Ref)).toEqual([ + notification.providers.InboxNotificationProvider, + MATRIX_PROVIDER + ]) + }) + + it('does not invent a type for a genuinely typeless notification', async () => { + const h = harness() + const notifyResult: NotifyResult = new Map([[notification.providers.InboxNotificationProvider, []]]) + + const txes = await commonTxes(h.control, notifyResult) + + expect(created(txes).attributes.types).toEqual([]) + }) + + it('leaves a type out of the cache for a provider that did not allow it', async () => { + // The §16.2.2 loop-breaker shape: matrix's own NewMessageNotification is in + // the matrix provider's ignoredTypes, so isAllowed drops the provider and + // it must not reappear in the cache. + const h = harness() + const notifyResult: NotifyResult = new Map([ + [notification.providers.InboxNotificationProvider, [{ _id: MATRIX_NEW_MESSAGE } as NotificationType]] + ]) + + const txes = await commonTxes(h.control, notifyResult) + const providers = cacheOf(h).get(created(txes).objectId as Ref) + + expect(providers).toEqual([notification.providers.InboxNotificationProvider]) + expect(providers).not.toContain(MATRIX_PROVIDER) + }) + + it('still writes nothing at all when the inbox provider is absent', async () => { + const h = harness() + const notifyResult: NotifyResult = new Map([[MATRIX_PROVIDER, [{ _id: TODO_CREATED } as NotificationType]]]) + + const txes = await commonTxes(h.control, notifyResult) + + expect(txes).toEqual([]) + expect(h.contextCache.get(AvailableProvidersCacheKey)).toBeUndefined() + }) +}) + +describe('the activity path is unchanged', () => { + it('keeps writing the types it is handed', async () => { + // The shape pushActivityInboxNotifications passes through for the + // issue-activity and DM/Thread paths that already worked. + const h = harness() + const res: Tx[] = [] + + await pushInboxNotifications( + measure, + h.control, + res, + RECEIVER, + SENDER, + OBJECT_ID, + OBJECT_CLASS, + OBJECT_SPACE, + [CONTEXT], + {}, + notification.class.ActivityInboxNotification, + 1, + [DM_NOTIFICATION], + true + ) + + expect((res[0] as TxCreateDoc).attributes.types).toEqual([DM_NOTIFICATION]) + }) +}) + +// ————— the hypothesis this fix had to disprove ————— +// +// The 2026-08-13 report blamed `getMatchedTypes` for attributing no type to a +// ToDo's bare TxCreateDoc. It does attribute one; the type was being dropped +// afterwards. These cases pin the matcher so the wrong suspect stays ruled out. + +interface ModelStub { + types: NotificationType[] + providers: NotificationProvider[] + defaults: Array<{ + provider: Ref + ignoredTypes: Array> + enabledTypes: Array> + }> +} + +function matcherControl (model: ModelStub): TriggerControl { + return { + modelDb: { + findAllSync: (_class: Ref>) => { + if (_class === notification.class.NotificationType) return model.types + if (_class === notification.class.NotificationProvider) return model.providers + if (_class === notification.class.NotificationProviderDefaults) return model.defaults + return [] + } + }, + hierarchy: { + getBaseClass: (c: Ref>) => c, + isDerived: (a: Ref>, b: Ref>) => a === b, + hasMixin: () => false + } + } as unknown as TriggerControl +} + +const todoCreatedType = { + _id: TODO_CREATED, + _class: notification.class.NotificationType, + txClasses: [core.class.TxCreateDoc], + objectClass: PROJECT_TODO, + onlyOwn: true, + allowedForAuthor: true, + defaultEnabled: false +} as unknown as NotificationType + +const matrixProvider = { + _id: MATRIX_PROVIDER, + _class: notification.class.NotificationProvider, + defaultEnabled: true +} as unknown as NotificationProvider + +const todoCreateTx = { + _class: core.class.TxCreateDoc, + objectClass: PROJECT_TODO, + objectId: 'todo-1' as Ref, + modifiedBy: SENDER.socialId +} as unknown as TxCUD + +describe('getMatchedTypes on a bare ToDo TxCreateDoc', () => { + it('matches the ToDo type when the matrix provider enables it', async () => { + const control = matcherControl({ + types: [todoCreatedType], + providers: [matrixProvider], + defaults: [{ provider: MATRIX_PROVIDER, ignoredTypes: [], enabledTypes: [TODO_CREATED] }] + }) + + const result = await isShouldNotifyTx( + control, + todoCreateTx, + { _id: 'todo-1' } as unknown as Doc, + RECEIVER, + true, + false, + new NotificationProviderControl([], []) + ) + + expect(result.get(MATRIX_PROVIDER)?.map((it) => it._id)).toEqual([TODO_CREATED]) + }) + + it('drops the type when the provider ignores it, so filtering still governs delivery', async () => { + const control = matcherControl({ + types: [todoCreatedType], + providers: [matrixProvider], + defaults: [{ provider: MATRIX_PROVIDER, ignoredTypes: [TODO_CREATED], enabledTypes: [] }] + }) + + const result = await isShouldNotifyTx( + control, + todoCreateTx, + { _id: 'todo-1' } as unknown as Doc, + RECEIVER, + true, + false, + new NotificationProviderControl([], []) + ) + + expect(result.has(MATRIX_PROVIDER)).toBe(false) + }) + + it('drops the type when it is neither enabled nor defaultEnabled', async () => { + const control = matcherControl({ + types: [todoCreatedType], + providers: [matrixProvider], + defaults: [{ provider: MATRIX_PROVIDER, ignoredTypes: [], enabledTypes: [] }] + }) + + const result = await isShouldNotifyTx( + control, + todoCreateTx, + { _id: 'todo-1' } as unknown as Doc, + RECEIVER, + true, + false, + new NotificationProviderControl([], []) + ) + + expect(result.has(MATRIX_PROVIDER)).toBe(false) + }) +}) diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 4a29f177819..462293f0b6b 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -114,8 +114,9 @@ export async function getCommonNotificationTxes ( const res: Tx[] = [] const notifyContexts = await control.findAll(ctx, notification.class.DocNotifyContext, { objectId: attachedTo }) + const types = (notifyResult.get(notification.providers.InboxNotificationProvider) ?? []).map((it) => it._id) - await pushInboxNotifications( + const notificationTx = await pushInboxNotifications( ctx, control, res, @@ -128,11 +129,20 @@ export async function getCommonNotificationTxes ( data, _class, modifiedOn, - [], + types, true, tx ) + if (notificationTx !== undefined) { + const current: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map() + const providers = Array.from(notifyResult.keys()) + if (providers.length > 0) { + current.set(notificationTx.objectId, providers) + control.contextCache.set(AvailableProvidersCacheKey, current) + } + } + return res }