Skip to content

Commit e2174de

Browse files
authored
Merge pull request #1378 from assemblycom/OUT-3929-per-category-iu-notification-gating
OUT-3929 | Gate IU notifications via platform notification settings
2 parents 299f10d + 74c7b39 commit e2174de

19 files changed

Lines changed: 469 additions & 143 deletions

src/app/api/notification/isIuEmailEnabled.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/app/api/notification/notification.service.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const mockGroupedCreateMany = jest.fn()
1515
const mockGetWorkspace = jest.fn()
1616
const mockMe = jest.fn()
1717
const mockCreateNotification = jest.fn()
18+
const mockGetNotificationSettings = jest.fn()
1819

1920
jest.mock('@/jobs/notifications/flush-grouped-email', () => ({
2021
enqueueGroupedEmailFlush: (...args: unknown[]) => mockEnqueueFlush(...args),
@@ -42,10 +43,12 @@ jest.mock('@/utils/CopilotAPI', () => ({
4243
getWorkspace: (...args: unknown[]) => mockGetWorkspace(...args),
4344
me: (...args: unknown[]) => mockMe(...args),
4445
createNotification: (...args: unknown[]) => mockCreateNotification(...args),
46+
getNotificationSettings: (...args: unknown[]) => mockGetNotificationSettings(...args),
4547
})),
4648
}))
4749

4850
import { NotificationService } from './notification.service'
51+
import { __clearNotificationSettingCache } from './resolveNotificationSettingId'
4952

5053
const user = {
5154
token: 'tok',
@@ -84,6 +87,16 @@ const buildService = () => {
8487

8588
beforeEach(() => {
8689
jest.clearAllMocks()
90+
__clearNotificationSettingCache()
91+
// Default: all IU categories declared with the email surface enabled, so IU emails buffer and
92+
// carry the resolved setting id. Individual cases override to exercise the email-surface gate.
93+
mockGetNotificationSettings.mockResolvedValue({
94+
notifications: [
95+
{ id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
96+
{ id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
97+
{ id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
98+
],
99+
})
87100
mockGetWorkspace.mockResolvedValue({ labels: {} })
88101
mockMe.mockResolvedValue({ id: 'creator_1', givenName: 'Jane', familyName: 'IU' })
89102
mockFindFirst.mockResolvedValue(null)
@@ -419,6 +432,42 @@ describe('guard: IU wiring boundaries', () => {
419432
})
420433
})
421434

435+
describe('guard: IU notifications ship ungated (settingId gating disabled)', () => {
436+
it('does not attach notificationSettingId to the in-product dispatch or the buffered email', async () => {
437+
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
438+
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
439+
440+
expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBeUndefined()
441+
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBeUndefined()
442+
})
443+
444+
it('still buffers the IU email and fires the in-product notification', async () => {
445+
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
446+
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
447+
448+
expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
449+
expect(deliveryTargetsOf(0).inProduct).toBeDefined()
450+
})
451+
452+
it('keeps IU grouped windows cross-category (window key is not scoped by event type)', async () => {
453+
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
454+
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
455+
456+
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].windowKey).toMatch(new RegExp(`^${task.assigneeId}:iu:[^:]+$`))
457+
})
458+
459+
it('treats a suppressed (null) createNotification response as a no-op — no throw, no save', async () => {
460+
// Platform dropped the only requested surface for this IU (preference off) → no created object.
461+
mockCreateNotification.mockResolvedValue(null)
462+
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
463+
464+
const result = await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
465+
466+
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
467+
expect(result).toBeUndefined()
468+
})
469+
})
470+
422471
describe('guard: IU completion emails', () => {
423472
// Every completion action routes to an IU; create() must buffer them as IU rows regardless of
424473
// which one is passed, so the guard stays consistent with groupedEventTypeFor.

src/app/api/notification/notification.service.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import APIError from '@api/core/exceptions/api'
1414
import { BaseService } from '@api/core/services/base.service'
1515
import { NotificationTaskActions } from '@api/core/types/tasks'
1616
import { getEmailDetails, getInProductNotificationDetails, mergeEmailOverride } from '@api/notification/notification.helpers'
17+
// import { resolveIuNotificationSettingId } from '@api/notification/resolveNotificationSettingId'
1718
import { AssigneeType, ClientNotification, GroupedEmailEventType, Prisma, Task } from '@prisma/client'
1819
import { randomUUID } from 'crypto'
1920
import { enqueueGroupedEmailFlush } from '@/jobs/notifications/flush-grouped-email'
@@ -28,12 +29,12 @@ export class NotificationService extends BaseService {
2829
action: NotificationTaskActions,
2930
task: Task,
3031
opts: {
31-
disableEmail: boolean
32+
disableEmail?: boolean
3233
disableInProduct?: boolean
3334
commentId?: string
3435
senderCompanyId?: string
3536
emailOverride?: EmailNotificationDetails
36-
} = { disableEmail: false },
37+
} = {},
3738
) {
3839
try {
3940
const isAssignedToIu =
@@ -72,7 +73,12 @@ export class NotificationService extends BaseService {
7273
: getEmailDetails(workspace, actionUser, task, { commentId: opts?.commentId })[action]
7374
const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail
7475

75-
const groupedType = email && recipientId ? this.groupedEventTypeFor(action) : null
76+
const category = this.groupedEventTypeFor(action)
77+
// TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now.
78+
const notificationSettingId = undefined
79+
// const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined
80+
81+
const groupedType = email && recipientId ? category : null
7682
if (groupedType) {
7783
const association = AssociationsSchema.parse(task.associations)?.[0]
7884
await this.bufferGroupedEmailEvent({
@@ -89,6 +95,7 @@ export class NotificationService extends BaseService {
8995
{ email },
9096
senderCompanyId,
9197
isRecipientIu,
98+
notificationSettingId,
9299
),
93100
})
94101
}
@@ -102,19 +109,17 @@ export class NotificationService extends BaseService {
102109
{ inProduct, email },
103110
senderCompanyId,
104111
isRecipientIu,
112+
notificationSettingId,
105113
)
106114
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
107115
if (!inProduct && !notificationDetails.deliveryTargets?.email) return
108116
console.info('NotificationService#create | Creating single notification:', notificationDetails)
109117

110-
let notification: NotificationCreatedResponse
111-
try {
112-
notification = await this.copilot.createNotification(notificationDetails)
113-
} catch (e: unknown) {
114-
notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
115-
}
118+
const notification = await this.dispatchNotification(notificationDetails)
116119

117120
console.info('NotificationService#create | Created single notification:', notification)
121+
// Suppressed by the recipient IU's preference — nothing was created, so there's nothing to save.
122+
if (!notification) return
118123

119124
// 3. Save notification to ClientNotification or InternalUserNotification table. Check for notification.recipientClientId too
120125
if (task.assigneeType === AssigneeType.client && !!notification.recipientClientId && !opts.disableInProduct) {
@@ -194,9 +199,13 @@ export class NotificationService extends BaseService {
194199
const iuNotifications = []
195200

196201
const association = AssociationsSchema.parse(task.associations)?.[0]
197-
// Non-null only when these emails should be diverted into the grouped buffer.
198-
const groupedType = email ? this.groupedEventTypeFor(action) : null
202+
const category = this.groupedEventTypeFor(action)
199203
const isRecipientIu = opts.isRecipientIu
204+
// TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now.
205+
const notificationSettingId = undefined
206+
// const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined
207+
// Non-null only when these emails should be diverted into the grouped buffer.
208+
const groupedType = email ? category : null
200209

201210
// NOTE: The reason we are skipping using NotificationService#create and implementing notification dispatch + save manually is because
202211
// we can just do one `createMany` DB call instead of one per notification, saving a ton of DB calls
@@ -227,6 +236,7 @@ export class NotificationService extends BaseService {
227236
{ email },
228237
opts?.senderCompanyId,
229238
isRecipientIu,
239+
notificationSettingId,
230240
),
231241
})
232242
if (!inProduct) continue
@@ -241,16 +251,12 @@ export class NotificationService extends BaseService {
241251
{ inProduct, email },
242252
opts?.senderCompanyId,
243253
isRecipientIu,
254+
notificationSettingId,
244255
)
245256
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
246257

247258
console.info('NotificationService#bulkCreate | Creating single notification:', notificationDetails)
248-
let notification: NotificationCreatedResponse
249-
try {
250-
notification = await this.copilot.createNotification(notificationDetails)
251-
} catch (e: unknown) {
252-
notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
253-
}
259+
const notification = await this.dispatchNotification(notificationDetails)
254260

255261
console.info('NotificationService#bulkCreate | Created single notification:', notification)
256262
if (!notification) {
@@ -580,6 +586,7 @@ export class NotificationService extends BaseService {
580586
)
581587
.map((iu) => iu.id)
582588
}
589+
break
583590
default:
584591
const userInfo = await this.copilot.me()
585592
senderId = z.string().parse(userInfo?.id)
@@ -637,7 +644,7 @@ export class NotificationService extends BaseService {
637644
}
638645
}
639646

640-
private async bufferGroupedEmailEvent(args: {
647+
async bufferGroupedEmailEvent(args: {
641648
task: Task
642649
recipientId: string
643650
companyId?: string
@@ -690,6 +697,16 @@ export class NotificationService extends BaseService {
690697
}
691698
}
692699

700+
private async dispatchNotification(
701+
notificationDetails: NotificationRequestBody,
702+
): Promise<NotificationCreatedResponse | null> {
703+
try {
704+
return await this.copilot.createNotification(notificationDetails)
705+
} catch (e: unknown) {
706+
return await this.handleIfSenderCompanyIdError(e, notificationDetails)
707+
}
708+
}
709+
693710
private async handleIfSenderCompanyIdError(e: unknown, notificationDetails: NotificationRequestBody) {
694711
// Account for workspaces that don't have multi-companies enabled, thus don't support the senderCompanyId key
695712
// Yes, this is hacky. No, I don't have a choice (I can't find out if workspace has single/multi company at all from the Copilot API)
@@ -715,6 +732,9 @@ export class NotificationService extends BaseService {
715732
deliveryTargets: NotificationRequestBody['deliveryTargets'],
716733
senderCompanyId?: string,
717734
isRecipientIu?: boolean,
735+
// Set for IU payloads so the platform gates each requested surface (in-product and email)
736+
// against the IU's per-category preference. Suppression is enforced platform-side.
737+
notificationSettingId?: string,
718738
): NotificationRequestBody {
719739
const associations = AssociationsSchema.parse(task.associations)
720740
const association = associations?.[0]
@@ -730,6 +750,7 @@ export class NotificationService extends BaseService {
730750
delete notificationDetails.recipientCompanyId
731751
delete notificationDetails.recipientClientId
732752
notificationDetails.recipientInternalUserId = recipientId
753+
if (notificationSettingId) notificationDetails.notificationSettingId = notificationSettingId
733754
}
734755
return notificationDetails
735756
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { CopilotAPI } from '@/utils/CopilotAPI'
2+
import { GroupedEmailEventType } from '@prisma/client'
3+
import { __clearNotificationSettingCache, resolveIuNotificationSettingId } from './resolveNotificationSettingId'
4+
5+
const buildCopilot = (getNotificationSettings: jest.Mock) => ({ getNotificationSettings }) as unknown as CopilotAPI
6+
7+
const settings = [
8+
{ id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
9+
{ id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
10+
{ id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
11+
]
12+
13+
beforeEach(() => __clearNotificationSettingCache())
14+
15+
describe('resolveIuNotificationSettingId', () => {
16+
it('maps each category to its declared setting id by label', async () => {
17+
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))
18+
19+
expect(
20+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
21+
).toBe('setting_assigned')
22+
expect(
23+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
24+
).toBe('setting_comment')
25+
expect(
26+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMPLETED }),
27+
).toBe('setting_completed')
28+
})
29+
30+
it('matches labels case-insensitively and ignoring surrounding whitespace', async () => {
31+
const copilot = buildCopilot(
32+
jest.fn().mockResolvedValue({ notifications: [{ id: 'x', label: ' NEW task Assigned ', surfaces: ['email'] }] }),
33+
)
34+
35+
expect(
36+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
37+
).toBe('x')
38+
})
39+
40+
it('returns undefined when the category is not declared', async () => {
41+
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: [settings[0]] }))
42+
43+
expect(
44+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
45+
).toBeUndefined()
46+
})
47+
48+
it('returns undefined for SHARED (no IU setting declared)', async () => {
49+
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))
50+
51+
expect(
52+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.SHARED }),
53+
).toBeUndefined()
54+
})
55+
56+
it('caches the label map per workspace and does not refetch within the TTL', async () => {
57+
const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
58+
const copilot = buildCopilot(getNotificationSettings)
59+
60+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
61+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT })
62+
63+
expect(getNotificationSettings).toHaveBeenCalledTimes(1)
64+
})
65+
66+
it('returns undefined when the fetch fails, without caching the failure', async () => {
67+
const getNotificationSettings = jest
68+
.fn()
69+
.mockRejectedValueOnce(new Error('copilot 5xx'))
70+
.mockResolvedValueOnce({ notifications: settings })
71+
const copilot = buildCopilot(getNotificationSettings)
72+
73+
expect(
74+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
75+
).toBeUndefined()
76+
expect(
77+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
78+
).toBe('setting_assigned')
79+
expect(getNotificationSettings).toHaveBeenCalledTimes(2)
80+
})
81+
82+
it('caches per workspace independently', async () => {
83+
const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
84+
const copilot = buildCopilot(getNotificationSettings)
85+
86+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
87+
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_2', category: GroupedEmailEventType.ASSIGNED })
88+
89+
expect(getNotificationSettings).toHaveBeenCalledTimes(2)
90+
})
91+
})

0 commit comments

Comments
 (0)