Skip to content

Commit 1e8ee73

Browse files
arpandhakalclaude
andcommitted
fix(notifications): gate IU in-product too + resilient settings fetch
Two follow-ups on OUT-3929: 1. Gate the in-product surface as well (per updated requirement). The notificationSettingId is now attached to the immediate in-product IU dispatch in create()/createBulkNotification, not just the buffered email, so the platform gates both surfaces against the IU's preference. We don't gate ourselves; we just pass the id. 2. Address Greptile review: - P1: resolveTasksNotificationSettingId catches getNotificationSettings failures and returns undefined instead of throwing. A failure no longer skips buffering + the in-product dispatch (which silently dropped the whole notification). Undefined id = platform delivers all (fail-open); the failure is not cached so the next send retries. With in-product now gated too, fail-open is the coherent choice - failing closed would mean dropping the entire notification during a settings-API outage. - P2: drop redundant `?? undefined` after `.find(Boolean)` in the flush. - P2: add the standard console.info trace to _getNotificationSettings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f73397a commit 1e8ee73

6 files changed

Lines changed: 47 additions & 17 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -544,10 +544,13 @@ describe('guard: IU notification setting (OUT-3929)', () => {
544544
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBeUndefined()
545545
})
546546

547-
it('does not stamp the setting id on the immediate in-product dispatch (only the buffered email)', async () => {
547+
it('also stamps the setting id on the immediate in-product dispatch so the platform gates in-product', async () => {
548548
await buildService().create(NotificationTaskActions.Assigned, iuTask(), { disableEmail: false })
549549

550-
// in-product notification fires now; suppressing it would break the notification center
551-
expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBeUndefined()
550+
const sent = mockCreateNotification.mock.calls[0][0]
551+
expect(sent.notificationSettingId).toBe('setting_tasks')
552+
// email diverted to the buffer; the immediate dispatch is in-product only, now gated by the setting
553+
expect(sent.deliveryTargets.inProduct).toBeDefined()
554+
expect(sent.deliveryTargets.email).toBeUndefined()
552555
})
553556
})

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,16 @@ export class NotificationService extends BaseService {
7474
const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail
7575

7676
const groupedType = email && recipientId ? this.groupedEventTypeFor(action) : null
77-
if (groupedType) {
78-
const association = AssociationsSchema.parse(task.associations)?.[0]
79-
// IU emails carry the Tasks notification setting so the platform enforces the IU's
80-
// per-surface preference. Stored on the buffered email, so both the grouped summary and
81-
// the single-event replay dispatch it.
82-
const notificationSettingId = isRecipientIu
77+
// IU notifications carry the Tasks notification setting so the platform gates BOTH the
78+
// in-product and email surfaces against the IU's preference. Resolved once and attached to the
79+
// buffered email (also drives the grouped summary + single-event replay) and the immediate
80+
// in-product dispatch below.
81+
const notificationSettingId =
82+
isRecipientIu && email
8383
? await resolveTasksNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId })
8484
: undefined
85+
if (groupedType) {
86+
const association = AssociationsSchema.parse(task.associations)?.[0]
8587
await this.bufferGroupedEmailEvent({
8688
task,
8789
recipientId,
@@ -110,6 +112,7 @@ export class NotificationService extends BaseService {
110112
{ inProduct, email },
111113
senderCompanyId,
112114
isRecipientIu,
115+
notificationSettingId,
113116
)
114117
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
115118
if (!inProduct && !notificationDetails.deliveryTargets?.email) return
@@ -256,6 +259,7 @@ export class NotificationService extends BaseService {
256259
{ inProduct, email },
257260
opts?.senderCompanyId,
258261
isRecipientIu,
262+
notificationSettingId,
259263
)
260264
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
261265

@@ -730,9 +734,8 @@ export class NotificationService extends BaseService {
730734
deliveryTargets: NotificationRequestBody['deliveryTargets'],
731735
senderCompanyId?: string,
732736
isRecipientIu?: boolean,
733-
// Set only for IU email payloads: the platform gates each surface against the IU's preference.
734-
// Intentionally omitted on the immediate in-product dispatch so notification-center delivery
735-
// (and our InternalUserNotification tracking) is never suppressed.
737+
// Set for IU payloads so the platform gates each requested surface (in-product and email)
738+
// against the IU's preference. Suppression is enforced platform-side; we just pass the id.
736739
notificationSettingId?: string,
737740
): NotificationRequestBody {
738741
const associations = AssociationsSchema.parse(task.associations)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ describe('resolveTasksNotificationSettingId', () => {
4242
expect(getNotificationSettings).toHaveBeenCalledTimes(1)
4343
})
4444

45+
it('falls back to undefined when the settings fetch fails, without caching the failure', async () => {
46+
const getNotificationSettings = jest
47+
.fn()
48+
.mockRejectedValueOnce(new Error('copilot 5xx'))
49+
.mockResolvedValueOnce({ notifications: [{ id: 'setting_tasks', label: 'x', surfaces: ['email'] }] })
50+
const copilot = buildCopilot(getNotificationSettings)
51+
52+
// first call fails to resolve → no suppression, and the failure is not cached
53+
expect(await resolveTasksNotificationSettingId({ copilot, workspaceId: 'ws_1' })).toBeUndefined()
54+
// next call retries and succeeds
55+
expect(await resolveTasksNotificationSettingId({ copilot, workspaceId: 'ws_1' })).toBe('setting_tasks')
56+
expect(getNotificationSettings).toHaveBeenCalledTimes(2)
57+
})
58+
4559
it('caches per workspace independently', async () => {
4660
const getNotificationSettings = jest.fn().mockResolvedValue({
4761
notifications: [{ id: 'setting_tasks', label: 'New task assigned', surfaces: ['email'] }],

src/app/api/notification/resolveNotificationSettingId.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CopilotAPI } from '@/utils/CopilotAPI'
2+
import { serializeError } from '@/utils/serializeError'
23

34
// The Tasks app declares a single notification setting for now, so every IU notification shares
45
// the sole declared setting's id. When we split into per-category settings (task assigned vs
@@ -22,10 +23,18 @@ export const resolveTasksNotificationSettingId = async ({
2223
const cached = cache.get(workspaceId)
2324
if (cached && cached.expiresAt > Date.now()) return cached.id
2425

25-
const { notifications } = await copilot.getNotificationSettings()
26-
const id = notifications[0]?.id
27-
cache.set(workspaceId, { id, expiresAt: Date.now() + CACHE_TTL_MS })
28-
return id
26+
try {
27+
const { notifications } = await copilot.getNotificationSettings()
28+
const id = notifications[0]?.id
29+
cache.set(workspaceId, { id, expiresAt: Date.now() + CACHE_TTL_MS })
30+
return id
31+
} catch (e) {
32+
// Never let settings resolution block delivery: fall back to no suppression (pre-OUT-3929
33+
// behavior — email is sent, just not gated by preference). Don't cache the failure so the next
34+
// send retries rather than staying degraded for the whole TTL.
35+
console.error('resolveTasksNotificationSettingId | failed to resolve; sending without suppression', serializeError(e))
36+
return undefined
37+
}
2938
}
3039

3140
// Test seam: clear the per-workspace cache between cases.

src/jobs/notifications/flush-grouped-email.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) =>
238238
senderCompanyId: sender?.senderCompanyId,
239239
recipientInternalUserId: group.recipientIuId,
240240
// All IU rows in a window share the single Tasks setting; read it off any buffered email.
241-
notificationSettingId: liveEvents.map((e) => e.individualEmail?.notificationSettingId).find(Boolean) ?? undefined,
241+
notificationSettingId: liveEvents.map((e) => e.individualEmail?.notificationSettingId).find(Boolean),
242242
copilot,
243243
})
244244
sent += 1

src/utils/CopilotAPI.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export class CopilotAPI {
352352
// (installs aren't in the token, so match by appId) then fetches its settings. Returns an empty
353353
// list when the app has no install/settings, so callers safely fall back to no suppression.
354354
async _getNotificationSettings(): Promise<NotificationSettingsResponse> {
355+
console.info('CopilotAPI#_getNotificationSettings', this.token)
355356
const appId = z.string({ message: 'Missing AppID in environment' }).parse(APP_ID)
356357
const installs = await this.copilot.listAppInstalls()
357358
const install = installs.find((entry) => entry.appId === appId)

0 commit comments

Comments
 (0)