Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/app/api/notification/iuEmailPreference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { InternalUserNotificationSettings } from '@/types/common'
import { disabledEmailSettingIds } from './iuEmailPreference'

const settings = (overrides: Partial<InternalUserNotificationSettings> = {}): InternalUserNotificationSettings => ({
emailSettings: 'active',
notifyAbout: {},
...overrides,
})

describe('disabledEmailSettingIds', () => {
it('collects ids only for categories with email disabled', () => {
const result = disabledEmailSettingIds(
settings({
notifyAbout: {
newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' },
newTaskAssigned: { disableEmail: false, notificationSettingId: 'setting_assigned' },
taskCompleted: { disableEmail: true, notificationSettingId: 'setting_completed' },
},
}),
)

expect(result).toEqual(new Set(['setting_comment', 'setting_completed']))
})

it('ignores platform categories without a notificationSettingId', () => {
const result = disabledEmailSettingIds(
settings({
notifyAbout: {
newMessages: { disableEmail: true },
newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' },
},
}),
)

expect(result).toEqual(new Set(['setting_comment']))
})

it('returns an empty set when nothing is disabled or notifyAbout is empty', () => {
expect(disabledEmailSettingIds(settings())).toEqual(new Set())
expect(
disabledEmailSettingIds(
settings({ notifyAbout: { newCommentOnATask: { disableEmail: false, notificationSettingId: 'setting_comment' } } }),
),
).toEqual(new Set())
})
})
10 changes: 10 additions & 0 deletions src/app/api/notification/iuEmailPreference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InternalUserNotificationSettings } from '@/types/common'

// Setting ids for which this IU has turned email off. Matched by notificationSettingId (our app's
// categories carry it), so this is independent of the platform's internal category key names.
export const disabledEmailSettingIds = (settings: InternalUserNotificationSettings): Set<string> => {
const ids = Object.values(settings.notifyAbout)
.filter((entry) => entry.notificationSettingId && entry.disableEmail)
.map((entry) => entry.notificationSettingId as string)
return new Set(ids)
}
8 changes: 4 additions & 4 deletions src/app/api/notification/notification.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,13 +432,13 @@ 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 () => {
describe('guard: IU notifications carry the resolved notificationSettingId (platform gating)', () => {
it('attaches the resolved notificationSettingId to the in-product dispatch and 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()
expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBe('setting_assigned')
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBe('setting_assigned')
})

it('still buffers the IU email and fires the in-product notification', async () => {
Expand Down
16 changes: 9 additions & 7 deletions src/app/api/notification/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +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 { 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 Down Expand Up @@ -74,9 +74,10 @@ export class NotificationService extends BaseService {
const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail

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 notificationSettingId =
isRecipientIu && category
? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category })
: undefined

const groupedType = email && recipientId ? category : null
if (groupedType) {
Expand Down Expand Up @@ -201,9 +202,10 @@ export class NotificationService extends BaseService {
const association = AssociationsSchema.parse(task.associations)?.[0]
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
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

Expand Down
57 changes: 54 additions & 3 deletions src/jobs/notifications/flush-grouped-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const mockQueryRaw = jest.fn()
const mockExecuteRaw = jest.fn()
const mockFindManyTask = jest.fn()
const mockGetInternalUsers = jest.fn()
const mockGetIuNotificationSettings = jest.fn()
const mockCaptureException = jest.fn()

jest.mock('@trigger.dev/sdk/v3', () => ({
Expand Down Expand Up @@ -35,9 +36,11 @@ jest.mock('@/lib/db', () => ({
}))

jest.mock('@/utils/CopilotAPI', () => ({
CopilotAPI: jest
.fn()
.mockImplementation(() => ({ getInternalUsers: mockGetInternalUsers, createNotification: mockCreateNotification })),
CopilotAPI: jest.fn().mockImplementation(() => ({
getInternalUsers: mockGetInternalUsers,
createNotification: mockCreateNotification,
getInternalUserNotificationSettings: mockGetIuNotificationSettings,
})),
}))

jest.mock('./send-grouped-email', () => ({
Expand Down Expand Up @@ -83,6 +86,8 @@ beforeEach(() => {
jest.clearAllMocks()
seq = 0
mockGetInternalUsers.mockResolvedValue({ data: [{ id: 'iu_1' }] })
// default: IU has all email categories enabled
mockGetIuNotificationSettings.mockResolvedValue({ emailSettings: 'active', notifyAbout: {} })
mockSendGroupedEmail.mockResolvedValue('notif_1')
mockCreateNotification.mockResolvedValue({ id: 'notif_1' })
mockExecuteRaw.mockResolvedValue(1)
Expand Down Expand Up @@ -161,6 +166,52 @@ describe('flushGroupedEmailRun', () => {
expect(mockSendGroupedEmail.mock.calls[0][0].notificationSettingId).toBeUndefined()
})

it('drops events for categories the IU disabled email on, keeping the rest', async () => {
mockGetIuNotificationSettings.mockResolvedValue({
emailSettings: 'active',
notifyAbout: {
newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' },
newTaskAssigned: { disableEmail: false, notificationSettingId: 'setting_assigned' },
},
})
mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')])

await flushGroupedEmailRun(payload)

// Only the assignment survives -> replayed as a single individual email, not a grouped summary.
expect(mockSendGroupedEmail).not.toHaveBeenCalled()
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
expect(mockCreateNotification.mock.calls[0][0]).toMatchObject({ notificationSettingId: 'setting_assigned' })
})

it('sends nothing to an IU who disabled email on every buffered category', async () => {
mockGetIuNotificationSettings.mockResolvedValue({
emailSettings: 'active',
notifyAbout: {
newCommentOnATask: { disableEmail: true, notificationSettingId: 'setting_comment' },
newTaskAssigned: { disableEmail: true, notificationSettingId: 'setting_assigned' },
},
})
mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')])

const result = await flushGroupedEmailRun(payload)

expect(mockSendGroupedEmail).not.toHaveBeenCalled()
expect(mockCreateNotification).not.toHaveBeenCalled()
expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows
expect(result).toMatchObject({ recipients: 1, sent: 0, sentGrouped: 0, sentIndividual: 0 })
})

it('sends ungated when the IU preference read fails (fail-open)', async () => {
mockGetIuNotificationSettings.mockRejectedValue(new Error('copilot 5xx'))
mockQueryRaw.mockResolvedValue([iuRow('setting_comment'), iuRow('setting_comment')])

await flushGroupedEmailRun(payload)

expect(mockSendGroupedEmail).toHaveBeenCalledTimes(1)
expect(mockSendGroupedEmail.mock.calls[0][0].content.totalEventCount).toBe(2)
})

it('replays the original individual email when the window has a single live event', async () => {
mockQueryRaw.mockResolvedValue([row()])

Expand Down
37 changes: 30 additions & 7 deletions src/jobs/notifications/flush-grouped-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'server-only'
import { randomUUID } from 'crypto'

import { composeGroupedEmail, GroupedEmailEventInput } from '@/app/api/notification/groupedEmail.composer'
import { disabledEmailSettingIds } from '@/app/api/notification/iuEmailPreference'
import { copilotAPIKey } from '@/config'
import { Sentry } from '@/jobs/sentry'
import DBClient from '@/lib/db'
Expand Down Expand Up @@ -87,12 +88,30 @@ const senderFromEvents = (events: WindowEvent[]): EventSender | undefined => {
return { senderId: email.senderId, senderType: email.senderType, senderCompanyId: email.senderCompanyId }
}

// Seam for per-IU category filtering. Today it's a pass-through: the platform can't gate a mixed
// grouped summary, and there's no API to read an IU's per-setting preferences. Once Assembly exposes
// that endpoint, fetch the recipient IU's prefs here and drop events for disabled categories so the
// summary only carries allowed ones (an all-disabled recipient then sends nothing).
// TODO(OUT-3929): implement per-IU filtering when the read endpoint lands.
const filterEventsForIuPreferences = (events: WindowEvent[]): WindowEvent[] => events
// Per-IU category filtering for grouped summaries. The platform can only gate a send that carries
// one setting id, so a mixed-category window is gated here instead: read the recipient IU's prefs
// and drop events for categories whose email is disabled (an all-disabled recipient sends nothing).
// Matched by the notificationSettingId the buffered email already carries. Fails open on read error.
const filterEventsForIuPreferences = async (
events: WindowEvent[],
recipientIuId: string,
copilot: CopilotAPI,
): Promise<WindowEvent[]> => {
try {
const settings = await copilot.getInternalUserNotificationSettings(recipientIuId)
const disabled = disabledEmailSettingIds(settings)
return events.filter((e) => {
const id = e.individualEmail?.notificationSettingId
return !id || !disabled.has(id)
})
} catch (e) {
logger.error('flush-grouped-email: failed to read IU prefs; sending ungated', {
recipientIuId,
error: serializeError(e),
})
return events
}
}

// The platform can only gate a send that carries one setting id. A grouped summary gets an id only
// when every live event shares the same one (i.e. a single-category window); a mixed window sends
Expand Down Expand Up @@ -232,7 +251,11 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) =>
}

for (const group of iuGroups) {
const liveEvents = filterEventsForIuPreferences(group.events.filter((e) => liveTaskIds.has(e.taskId)))
const liveEvents = await filterEventsForIuPreferences(
group.events.filter((e) => liveTaskIds.has(e.taskId)),
group.recipientIuId,
copilot,
)
// A single event replays its buffered email verbatim (it already carries its own setting id).
const singleEmail = liveEvents.length === 1 ? liveEvents[0].individualEmail : null

Expand Down
16 changes: 16 additions & 0 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,22 @@ export const NotificationSettingsResponseSchema = z.object({
})
export type NotificationSettingsResponse = z.infer<typeof NotificationSettingsResponseSchema>

// A single IU's per-category notification preference (GET /v1/internal-users/:id/notification-settings).
// Only our app's categories carry appId + notificationSettingId; the platform's own categories don't.
export const IuNotifyAboutEntrySchema = z.object({
disableInProduct: z.boolean().optional(),
disableEmail: z.boolean().optional(),
appId: z.string().optional(),
notificationSettingId: z.string().optional(),
})

export const InternalUserNotificationSettingsSchema = z.object({
disableInProduct: z.boolean().optional(),
emailSettings: z.string().optional(),
notifyAbout: z.record(z.string(), IuNotifyAboutEntrySchema).default({}),
})
export type InternalUserNotificationSettings = z.infer<typeof InternalUserNotificationSettingsSchema>

export const ScrapMediaRequestSchema = z.object({
filePath: z.string(),
taskId: z.string().uuid().optional(),
Expand Down
12 changes: 12 additions & 0 deletions src/utils/CopilotAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
CopilotListArgs,
CustomFieldResponse,
CustomFieldResponseSchema,
InternalUserNotificationSettings,
InternalUserNotificationSettingsSchema,
InternalUsers,
InternalUsersResponse,
InternalUsersResponseSchema,
Expand Down Expand Up @@ -370,6 +372,15 @@ export class CopilotAPI {
return NotificationSettingsResponseSchema.parse(response)
}

// A single IU's live per-category notification preferences. Never cached — the platform evaluates
// this on every send and IUs can toggle it at any time.
async _getInternalUserNotificationSettings(id: string): Promise<InternalUserNotificationSettings> {
console.info('CopilotAPI#_getInternalUserNotificationSettings', id)
const workspaceId = await this._resolveWorkspaceId()
const response = await this._manualFetch(`internal-users/${id}/notification-settings`, undefined, workspaceId)
return InternalUserNotificationSettingsSchema.parse(response)
}

async dispatchWebhook(
eventName: DISPATCHABLE_EVENT,
{
Expand Down Expand Up @@ -435,6 +446,7 @@ export class CopilotAPI {
manualFetch = this.wrapWithRetry(this._manualFetch)
getIUNotification = this.wrapWithRetry(this._getIUNotification)
getNotificationSettings = this.wrapWithRetry(this._getNotificationSettings)
getInternalUserNotificationSettings = this.wrapWithRetry(this._getInternalUserNotificationSettings)
}

const cachedFetchInternalUser = cache(
Expand Down
Loading