Skip to content
Merged
3 changes: 0 additions & 3 deletions src/app/api/notification/isIuEmailEnabled.ts

This file was deleted.

49 changes: 49 additions & 0 deletions src/app/api/notification/notification.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockGroupedCreateMany = jest.fn()
const mockGetWorkspace = jest.fn()
const mockMe = jest.fn()
const mockCreateNotification = jest.fn()
const mockGetNotificationSettings = jest.fn()

jest.mock('@/jobs/notifications/flush-grouped-email', () => ({
enqueueGroupedEmailFlush: (...args: unknown[]) => mockEnqueueFlush(...args),
Expand Down Expand Up @@ -42,10 +43,12 @@ jest.mock('@/utils/CopilotAPI', () => ({
getWorkspace: (...args: unknown[]) => mockGetWorkspace(...args),
me: (...args: unknown[]) => mockMe(...args),
createNotification: (...args: unknown[]) => mockCreateNotification(...args),
getNotificationSettings: (...args: unknown[]) => mockGetNotificationSettings(...args),
})),
}))

import { NotificationService } from './notification.service'
import { __clearNotificationSettingCache } from './resolveNotificationSettingId'

const user = {
token: 'tok',
Expand Down Expand Up @@ -84,6 +87,16 @@ const buildService = () => {

beforeEach(() => {
jest.clearAllMocks()
__clearNotificationSettingCache()
// Default: all IU categories declared with the email surface enabled, so IU emails buffer and
// carry the resolved setting id. Individual cases override to exercise the email-surface gate.
mockGetNotificationSettings.mockResolvedValue({
notifications: [
{ id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
{ id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
{ id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
],
})
mockGetWorkspace.mockResolvedValue({ labels: {} })
mockMe.mockResolvedValue({ id: 'creator_1', givenName: 'Jane', familyName: 'IU' })
mockFindFirst.mockResolvedValue(null)
Expand Down Expand Up @@ -419,6 +432,42 @@ describe('guard: IU wiring boundaries', () => {
})
})

describe('guard: IU notifications ship ungated (settingId gating disabled)', () => {
it('does not attach notificationSettingId to the in-product dispatch or the buffered email', async () => {
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })

expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBeUndefined()
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBeUndefined()
})

it('still buffers the IU email and fires the in-product notification', async () => {
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })

expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
expect(deliveryTargetsOf(0).inProduct).toBeDefined()
})

it('keeps IU grouped windows cross-category (window key is not scoped by event type)', async () => {
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })

expect(mockGroupedCreateMany.mock.calls[0][0].data[0].windowKey).toMatch(new RegExp(`^${task.assigneeId}:iu:[^:]+$`))
})

it('treats a suppressed (null) createNotification response as a no-op — no throw, no save', async () => {
// Platform dropped the only requested surface for this IU (preference off) → no created object.
mockCreateNotification.mockResolvedValue(null)
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })

const result = await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })

expect(mockCreateNotification).toHaveBeenCalledTimes(1)
expect(result).toBeUndefined()
})
})

describe('guard: IU completion emails', () => {
// Every completion action routes to an IU; create() must buffer them as IU rows regardless of
// which one is passed, so the guard stays consistent with groupedEventTypeFor.
Expand Down
57 changes: 39 additions & 18 deletions src/app/api/notification/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import APIError from '@api/core/exceptions/api'
import { BaseService } from '@api/core/services/base.service'
import { NotificationTaskActions } from '@api/core/types/tasks'
import { getEmailDetails, getInProductNotificationDetails, mergeEmailOverride } from '@api/notification/notification.helpers'
// import { resolveIuNotificationSettingId } from '@api/notification/resolveNotificationSettingId'
import { AssigneeType, ClientNotification, GroupedEmailEventType, Prisma, Task } from '@prisma/client'
import { randomUUID } from 'crypto'
import { enqueueGroupedEmailFlush } from '@/jobs/notifications/flush-grouped-email'
Expand All @@ -28,12 +29,12 @@ export class NotificationService extends BaseService {
action: NotificationTaskActions,
task: Task,
opts: {
disableEmail: boolean
disableEmail?: boolean
disableInProduct?: boolean
commentId?: string
senderCompanyId?: string
emailOverride?: EmailNotificationDetails
} = { disableEmail: false },
} = {},
) {
try {
const isAssignedToIu =
Expand Down Expand Up @@ -72,7 +73,12 @@ export class NotificationService extends BaseService {
: getEmailDetails(workspace, actionUser, task, { commentId: opts?.commentId })[action]
const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail

const groupedType = email && recipientId ? this.groupedEventTypeFor(action) : null
const category = this.groupedEventTypeFor(action)
// TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now.
const notificationSettingId = undefined
// const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined

const groupedType = email && recipientId ? category : null
if (groupedType) {
const association = AssociationsSchema.parse(task.associations)?.[0]
await this.bufferGroupedEmailEvent({
Expand All @@ -89,6 +95,7 @@ export class NotificationService extends BaseService {
{ email },
senderCompanyId,
isRecipientIu,
notificationSettingId,
),
})
}
Expand All @@ -102,19 +109,17 @@ export class NotificationService extends BaseService {
{ inProduct, email },
senderCompanyId,
isRecipientIu,
notificationSettingId,
)
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
if (!inProduct && !notificationDetails.deliveryTargets?.email) return
console.info('NotificationService#create | Creating single notification:', notificationDetails)

let notification: NotificationCreatedResponse
try {
notification = await this.copilot.createNotification(notificationDetails)
} catch (e: unknown) {
notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
}
const notification = await this.dispatchNotification(notificationDetails)

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

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

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

// NOTE: The reason we are skipping using NotificationService#create and implementing notification dispatch + save manually is because
// we can just do one `createMany` DB call instead of one per notification, saving a ton of DB calls
Expand Down Expand Up @@ -227,6 +236,7 @@ export class NotificationService extends BaseService {
{ email },
opts?.senderCompanyId,
isRecipientIu,
notificationSettingId,
),
})
if (!inProduct) continue
Expand All @@ -241,16 +251,12 @@ export class NotificationService extends BaseService {
{ inProduct, email },
opts?.senderCompanyId,
isRecipientIu,
notificationSettingId,
)
if (groupedType) notificationDetails.deliveryTargets = { inProduct }

console.info('NotificationService#bulkCreate | Creating single notification:', notificationDetails)
let notification: NotificationCreatedResponse
try {
notification = await this.copilot.createNotification(notificationDetails)
} catch (e: unknown) {
notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
}
const notification = await this.dispatchNotification(notificationDetails)

console.info('NotificationService#bulkCreate | Created single notification:', notification)
if (!notification) {
Expand Down Expand Up @@ -580,6 +586,7 @@ export class NotificationService extends BaseService {
)
.map((iu) => iu.id)
}
break
default:
const userInfo = await this.copilot.me()
senderId = z.string().parse(userInfo?.id)
Expand Down Expand Up @@ -637,7 +644,7 @@ export class NotificationService extends BaseService {
}
}

private async bufferGroupedEmailEvent(args: {
async bufferGroupedEmailEvent(args: {
task: Task
recipientId: string
companyId?: string
Expand Down Expand Up @@ -690,6 +697,16 @@ export class NotificationService extends BaseService {
}
}

private async dispatchNotification(
notificationDetails: NotificationRequestBody,
): Promise<NotificationCreatedResponse | null> {
try {
return await this.copilot.createNotification(notificationDetails)
} catch (e: unknown) {
return await this.handleIfSenderCompanyIdError(e, notificationDetails)
}
}

private async handleIfSenderCompanyIdError(e: unknown, notificationDetails: NotificationRequestBody) {
// Account for workspaces that don't have multi-companies enabled, thus don't support the senderCompanyId key
// 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)
Expand All @@ -715,6 +732,9 @@ export class NotificationService extends BaseService {
deliveryTargets: NotificationRequestBody['deliveryTargets'],
senderCompanyId?: string,
isRecipientIu?: boolean,
// Set for IU payloads so the platform gates each requested surface (in-product and email)
// against the IU's per-category preference. Suppression is enforced platform-side.
notificationSettingId?: string,
): NotificationRequestBody {
const associations = AssociationsSchema.parse(task.associations)
const association = associations?.[0]
Expand All @@ -730,6 +750,7 @@ export class NotificationService extends BaseService {
delete notificationDetails.recipientCompanyId
delete notificationDetails.recipientClientId
notificationDetails.recipientInternalUserId = recipientId
if (notificationSettingId) notificationDetails.notificationSettingId = notificationSettingId
}
return notificationDetails
}
Expand Down
91 changes: 91 additions & 0 deletions src/app/api/notification/resolveNotificationSettingId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { CopilotAPI } from '@/utils/CopilotAPI'
import { GroupedEmailEventType } from '@prisma/client'
import { __clearNotificationSettingCache, resolveIuNotificationSettingId } from './resolveNotificationSettingId'

const buildCopilot = (getNotificationSettings: jest.Mock) => ({ getNotificationSettings }) as unknown as CopilotAPI

const settings = [
{ id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
{ id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
{ id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
]

beforeEach(() => __clearNotificationSettingCache())

describe('resolveIuNotificationSettingId', () => {
it('maps each category to its declared setting id by label', async () => {
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))

expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
).toBe('setting_assigned')
expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
).toBe('setting_comment')
expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMPLETED }),
).toBe('setting_completed')
})

it('matches labels case-insensitively and ignoring surrounding whitespace', async () => {
const copilot = buildCopilot(
jest.fn().mockResolvedValue({ notifications: [{ id: 'x', label: ' NEW task Assigned ', surfaces: ['email'] }] }),
)

expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
).toBe('x')
})

it('returns undefined when the category is not declared', async () => {
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: [settings[0]] }))

expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
).toBeUndefined()
})

it('returns undefined for SHARED (no IU setting declared)', async () => {
const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))

expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.SHARED }),
).toBeUndefined()
})

it('caches the label map per workspace and does not refetch within the TTL', async () => {
const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
const copilot = buildCopilot(getNotificationSettings)

await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT })

expect(getNotificationSettings).toHaveBeenCalledTimes(1)
})

it('returns undefined when the fetch fails, without caching the failure', async () => {
const getNotificationSettings = jest
.fn()
.mockRejectedValueOnce(new Error('copilot 5xx'))
.mockResolvedValueOnce({ notifications: settings })
const copilot = buildCopilot(getNotificationSettings)

expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
).toBeUndefined()
expect(
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
).toBe('setting_assigned')
expect(getNotificationSettings).toHaveBeenCalledTimes(2)
})

it('caches per workspace independently', async () => {
const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
const copilot = buildCopilot(getNotificationSettings)

await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_2', category: GroupedEmailEventType.ASSIGNED })

expect(getNotificationSettings).toHaveBeenCalledTimes(2)
})
})
Loading
Loading