diff --git a/src/app/api/notification/iuEmailPreference.test.ts b/src/app/api/notification/iuEmailPreference.test.ts new file mode 100644 index 000000000..d443647c1 --- /dev/null +++ b/src/app/api/notification/iuEmailPreference.test.ts @@ -0,0 +1,46 @@ +import { InternalUserNotificationSettings } from '@/types/common' +import { disabledEmailSettingIds } from './iuEmailPreference' + +const settings = (overrides: Partial = {}): 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()) + }) +}) diff --git a/src/app/api/notification/iuEmailPreference.ts b/src/app/api/notification/iuEmailPreference.ts new file mode 100644 index 000000000..e9a17af7e --- /dev/null +++ b/src/app/api/notification/iuEmailPreference.ts @@ -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 => { + const ids = Object.values(settings.notifyAbout) + .filter((entry) => entry.notificationSettingId && entry.disableEmail) + .map((entry) => entry.notificationSettingId as string) + return new Set(ids) +} diff --git a/src/app/api/notification/notification.service.test.ts b/src/app/api/notification/notification.service.test.ts index acda3543b..b28a6051b 100644 --- a/src/app/api/notification/notification.service.test.ts +++ b/src/app/api/notification/notification.service.test.ts @@ -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 () => { diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index f65b28211..cd2a1de65 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -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' @@ -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) { @@ -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 diff --git a/src/jobs/notifications/flush-grouped-email.test.ts b/src/jobs/notifications/flush-grouped-email.test.ts index ec3bcfafe..e05b33a31 100644 --- a/src/jobs/notifications/flush-grouped-email.test.ts +++ b/src/jobs/notifications/flush-grouped-email.test.ts @@ -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', () => ({ @@ -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', () => ({ @@ -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) @@ -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()]) diff --git a/src/jobs/notifications/flush-grouped-email.ts b/src/jobs/notifications/flush-grouped-email.ts index 496ebc4c9..b40a69784 100644 --- a/src/jobs/notifications/flush-grouped-email.ts +++ b/src/jobs/notifications/flush-grouped-email.ts @@ -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' @@ -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 => { + 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 @@ -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 diff --git a/src/types/common.ts b/src/types/common.ts index 119adb64f..2d9028af7 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -237,6 +237,22 @@ export const NotificationSettingsResponseSchema = z.object({ }) export type NotificationSettingsResponse = z.infer +// 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 + export const ScrapMediaRequestSchema = z.object({ filePath: z.string(), taskId: z.string().uuid().optional(), diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index 82e4ed7e6..eed475e79 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -19,6 +19,8 @@ import { CopilotListArgs, CustomFieldResponse, CustomFieldResponseSchema, + InternalUserNotificationSettings, + InternalUserNotificationSettingsSchema, InternalUsers, InternalUsersResponse, InternalUsersResponseSchema, @@ -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 { + 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, { @@ -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(