diff --git a/src/jobs/notifications/flush-grouped-email.integration.test.ts b/src/jobs/notifications/flush-grouped-email.integration.test.ts index 2cd83e1ad..ef3c5f73e 100644 --- a/src/jobs/notifications/flush-grouped-email.integration.test.ts +++ b/src/jobs/notifications/flush-grouped-email.integration.test.ts @@ -244,6 +244,10 @@ describe('flush-grouped-email idempotency (real DB)', () => { // Only the live task appears in the grouped email (1 event); individual snapshot path. expect(mockCreateNotification).toHaveBeenCalledTimes(1) + expect(mockCreateNotification.mock.calls[0][0]).toMatchObject({ + recipientClientId: CLIENT_A, + recipientCompanyId: COMPANY, + }) expect(result).toMatchObject({ sentGrouped: 0, sentIndividual: 1 }) expect(await totalCount(window)).toBe(0) }) diff --git a/src/jobs/notifications/flush-grouped-email.test.ts b/src/jobs/notifications/flush-grouped-email.test.ts index f59f13f2d..895c3992e 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 mockGetClient = 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, + getClient: mockGetClient, + createNotification: mockCreateNotification, + })), })) jest.mock('./send-grouped-email', () => ({ @@ -83,6 +86,7 @@ beforeEach(() => { jest.clearAllMocks() seq = 0 mockGetInternalUsers.mockResolvedValue({ data: [{ id: 'iu_1' }] }) + mockGetClient.mockResolvedValue({ companyId: 'company_resolved' }) mockSendGroupedEmail.mockResolvedValue('notif_1') mockCreateNotification.mockResolvedValue({ id: 'notif_1' }) mockExecuteRaw.mockResolvedValue(1) @@ -113,13 +117,26 @@ describe('flushGroupedEmailRun', () => { const result = await flushGroupedEmailRun(payload) expect(mockCreateNotification).toHaveBeenCalledTimes(1) - expect(mockCreateNotification).toHaveBeenCalledWith(expect.objectContaining({ recipientClientId: 'client_1' })) + expect(mockCreateNotification).toHaveBeenCalledWith( + expect.objectContaining({ recipientClientId: 'client_1', recipientCompanyId: 'company_1' }), + ) expect(mockSendGroupedEmail).not.toHaveBeenCalled() expect(mockGetInternalUsers).not.toHaveBeenCalled() // no workspace IU needed for the individual path expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 0, sentIndividual: 1 }) }) + it('looks up the client company when an individual replay row has no company id', async () => { + mockQueryRaw.mockResolvedValue([row({ recipientCompanyId: null })]) + + await flushGroupedEmailRun(payload) + + expect(mockGetClient).toHaveBeenCalledWith('client_1') + expect(mockCreateNotification).toHaveBeenCalledWith( + expect.objectContaining({ recipientClientId: 'client_1', recipientCompanyId: 'company_resolved' }), + ) + }) + it('falls back to the grouped summary when a single event has no snapshot (pre-migration row)', async () => { mockQueryRaw.mockResolvedValue([row({ individualEmail: null })]) @@ -190,7 +207,10 @@ describe('flushGroupedEmailRun', () => { await flushGroupedEmailRun(payload) expect(mockCreateNotification).toHaveBeenCalledTimes(2) - expect(mockCreateNotification.mock.calls[1][0]).toMatchObject({ senderCompanyId: undefined }) + expect(mockCreateNotification.mock.calls[1][0]).toMatchObject({ + recipientCompanyId: 'company_1', + senderCompanyId: undefined, + }) expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows }) diff --git a/src/jobs/notifications/flush-grouped-email.ts b/src/jobs/notifications/flush-grouped-email.ts index 47ab2a51c..407d5bb6c 100644 --- a/src/jobs/notifications/flush-grouped-email.ts +++ b/src/jobs/notifications/flush-grouped-email.ts @@ -13,6 +13,7 @@ import { serializeError } from '@/utils/serializeError' import { logger, task, tasks } from '@trigger.dev/sdk/v3' import { sendGroupedEmail } from './send-grouped-email' +import { resolveClientRecipient } from './resolve-recipient-company' export type FlushGroupedEmailPayload = { workspaceId: string @@ -60,13 +61,22 @@ const resolveSenderId = async (copilot: CopilotAPI): Promise => { return senderId } -const sendIndividualEmail = async (copilot: CopilotAPI, payload: NotificationRequestBody): Promise => { +const sendIndividualEmail = async ({ + copilot, + payload, + recipientCompanyId, +}: { + copilot: CopilotAPI + payload: NotificationRequestBody + recipientCompanyId: string | null +}): Promise => { + const resolvedPayload = await resolveClientRecipient({ copilot, payload, recipientCompanyId }) try { - await copilot.createNotification(payload) + await copilot.createNotification(resolvedPayload) } catch (e: unknown) { // Account for workspaces without multi-companies, which reject senderCompanyId (mirrors NotificationService). if (isMessagableError(e) && e.body?.message === 'sender company ID is invalid based on sender') { - await copilot.createNotification({ ...payload, senderCompanyId: undefined }) + await copilot.createNotification({ ...resolvedPayload, senderCompanyId: undefined }) } else { throw e } @@ -146,7 +156,7 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => }) if (singleEmail) { - await sendIndividualEmail(copilot, singleEmail) + await sendIndividualEmail({ copilot, payload: singleEmail, recipientCompanyId: group.recipientCompanyId }) sent += 1 sentIndividual += 1 } else if (liveEvents.length >= 1) { diff --git a/src/jobs/notifications/resolve-recipient-company.ts b/src/jobs/notifications/resolve-recipient-company.ts new file mode 100644 index 000000000..f6eaf525a --- /dev/null +++ b/src/jobs/notifications/resolve-recipient-company.ts @@ -0,0 +1,38 @@ +import { NotificationRequestBody } from '@/types/common' +import { CopilotAPI } from '@/utils/CopilotAPI' + +export const resolveRecipientCompanyId = async ({ + copilot, + recipientClientId, + recipientCompanyId, +}: { + copilot: CopilotAPI + recipientClientId: string + recipientCompanyId?: string | null +}): Promise => { + if (recipientCompanyId) return recipientCompanyId + + const client = await copilot.getClient(recipientClientId) + return client.companyId +} + +export const resolveClientRecipient = async ({ + copilot, + payload, + recipientCompanyId, +}: { + copilot: CopilotAPI + payload: NotificationRequestBody + recipientCompanyId?: string | null +}): Promise => { + if (!payload.recipientClientId || payload.recipientCompanyId) return payload + + return { + ...payload, + recipientCompanyId: await resolveRecipientCompanyId({ + copilot, + recipientClientId: payload.recipientClientId, + recipientCompanyId, + }), + } +} diff --git a/src/jobs/notifications/send-grouped-email.test.ts b/src/jobs/notifications/send-grouped-email.test.ts index c41215706..c31dd68f4 100644 --- a/src/jobs/notifications/send-grouped-email.test.ts +++ b/src/jobs/notifications/send-grouped-email.test.ts @@ -15,7 +15,8 @@ const content: GroupedEmailContent = { ], } -const buildCopilotMock = (createNotification: jest.Mock) => ({ createNotification }) as unknown as CopilotAPI +const buildCopilotMock = (createNotification: jest.Mock, getClient = jest.fn()) => + ({ createNotification, getClient }) as unknown as CopilotAPI describe('sendGroupedEmail', () => { it('returns the Copilot notification id', async () => { @@ -60,18 +61,20 @@ describe('sendGroupedEmail', () => { expect(payload.deliveryTargets.inProduct).toBeUndefined() }) - it('omits recipientCompanyId when null', async () => { + it('resolves recipientCompanyId when missing', async () => { const createNotification = jest.fn().mockResolvedValue({ id: 'notif_2', createdAt: '2026-06-09T00:00:00Z' }) + const getClient = jest.fn().mockResolvedValue({ companyId: 'company_resolved' }) await sendGroupedEmail({ content, senderId: 'iu_1', recipientClientId: 'client_1', recipientCompanyId: null, - copilot: buildCopilotMock(createNotification), + copilot: buildCopilotMock(createNotification, getClient), }) - expect(createNotification.mock.calls[0][0].recipientCompanyId).toBeUndefined() + expect(getClient).toHaveBeenCalledWith('client_1') + expect(createNotification.mock.calls[0][0].recipientCompanyId).toBe('company_resolved') }) it('propagates errors from Copilot', async () => { diff --git a/src/jobs/notifications/send-grouped-email.ts b/src/jobs/notifications/send-grouped-email.ts index 14d0f9bcf..dac900ca4 100644 --- a/src/jobs/notifications/send-grouped-email.ts +++ b/src/jobs/notifications/send-grouped-email.ts @@ -4,6 +4,7 @@ import { GroupedEmailContent } from '@/app/api/notification/groupedEmail.compose import { renderGroupedEmail } from '@/app/api/notification/groupedEmail.renderer' import { NotificationRequestBody } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' +import { resolveRecipientCompanyId } from './resolve-recipient-company' export type SendGroupedEmailArgs = { content: GroupedEmailContent @@ -21,12 +22,13 @@ export const sendGroupedEmail = async ({ copilot, }: SendGroupedEmailArgs): Promise => { const email = renderGroupedEmail(content) + const resolvedRecipientCompanyId = await resolveRecipientCompanyId({ copilot, recipientClientId, recipientCompanyId }) const payload: NotificationRequestBody = { senderId, senderType: 'internalUser', recipientClientId, - recipientCompanyId: recipientCompanyId ?? undefined, + recipientCompanyId: resolvedRecipientCompanyId, deliveryTargets: { email: { subject: email.subject, diff --git a/src/jobs/notifications/send-grouped-reminder-email.test.ts b/src/jobs/notifications/send-grouped-reminder-email.test.ts new file mode 100644 index 000000000..23310a4f1 --- /dev/null +++ b/src/jobs/notifications/send-grouped-reminder-email.test.ts @@ -0,0 +1,59 @@ +import { CopilotAPI } from '@/utils/CopilotAPI' +import { TaskReminderType } from '@prisma/client' +import { sendGroupedReminderEmail } from './send-grouped-reminder-email' + +const entries = [ + { taskTitle: 'Task A', reminderType: TaskReminderType.DUE_DATE_TODAY }, + { taskTitle: 'Task B', reminderType: TaskReminderType.NO_DUE_DATE_3D }, +] + +const buildCopilotMock = (createNotification: jest.Mock, getClient = jest.fn()) => + ({ createNotification, getClient }) as unknown as CopilotAPI + +describe('sendGroupedReminderEmail', () => { + it('builds an email-only payload with a resolved client company', async () => { + const createNotification = jest.fn().mockResolvedValue({ id: 'notif_1', createdAt: '2026-06-09T00:00:00Z' }) + + const id = await sendGroupedReminderEmail({ + entries, + senderId: 'iu_1', + recipientClientId: 'client_1', + recipientCompanyId: 'company_1', + copilot: buildCopilotMock(createNotification), + }) + + expect(id).toBe('notif_1') + expect(createNotification).toHaveBeenCalledTimes(1) + expect(createNotification.mock.calls[0][0]).toMatchObject({ + senderId: 'iu_1', + senderType: 'internalUser', + recipientClientId: 'client_1', + recipientCompanyId: 'company_1', + deliveryTargets: { + email: { + subject: '[Reminder] You have 2 tasks to complete', + header: 'Tasks that need your attention', + title: 'View all tasks', + htmlBody: expect.stringContaining('Due soon'), + }, + }, + }) + expect(createNotification.mock.calls[0][0].deliveryTargets.inProduct).toBeUndefined() + }) + + it('looks up the client company when the caller has no company id', async () => { + const createNotification = jest.fn().mockResolvedValue({ id: 'notif_2', createdAt: '2026-06-09T00:00:00Z' }) + const getClient = jest.fn().mockResolvedValue({ companyId: 'company_resolved' }) + + await sendGroupedReminderEmail({ + entries, + senderId: 'iu_1', + recipientClientId: 'client_1', + recipientCompanyId: null, + copilot: buildCopilotMock(createNotification, getClient), + }) + + expect(getClient).toHaveBeenCalledWith('client_1') + expect(createNotification.mock.calls[0][0].recipientCompanyId).toBe('company_resolved') + }) +}) diff --git a/src/jobs/notifications/send-grouped-reminder-email.ts b/src/jobs/notifications/send-grouped-reminder-email.ts index c8f78feb9..9598a9581 100644 --- a/src/jobs/notifications/send-grouped-reminder-email.ts +++ b/src/jobs/notifications/send-grouped-reminder-email.ts @@ -3,6 +3,7 @@ import 'server-only' import { ReminderEntry, renderGroupedReminderEmail } from '@/app/api/notification/groupedReminderEmail.renderer' import { NotificationRequestBody } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' +import { resolveRecipientCompanyId } from './resolve-recipient-company' export type SendGroupedReminderEmailArgs = { entries: ReminderEntry[] @@ -20,12 +21,13 @@ export const sendGroupedReminderEmail = async ({ copilot, }: SendGroupedReminderEmailArgs): Promise => { const email = renderGroupedReminderEmail(entries) + const resolvedRecipientCompanyId = await resolveRecipientCompanyId({ copilot, recipientClientId, recipientCompanyId }) const payload: NotificationRequestBody = { senderId, senderType: 'internalUser', recipientClientId, - recipientCompanyId: recipientCompanyId ?? undefined, + recipientCompanyId: resolvedRecipientCompanyId, deliveryTargets: { email: { subject: email.subject, diff --git a/src/jobs/notifications/send-reminder-email.test.ts b/src/jobs/notifications/send-reminder-email.test.ts index 9437bbb5d..8f7a4cfb6 100644 --- a/src/jobs/notifications/send-reminder-email.test.ts +++ b/src/jobs/notifications/send-reminder-email.test.ts @@ -22,7 +22,8 @@ const workspace: WorkspaceResponse = { const task = { id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' } -const buildCopilotMock = (createNotification: jest.Mock) => ({ createNotification }) as unknown as CopilotAPI +const buildCopilotMock = (createNotification: jest.Mock, getClient = jest.fn()) => + ({ createNotification, getClient }) as unknown as CopilotAPI describe('sendReminderEmail', () => { it('returns the Copilot notification id', async () => { @@ -89,8 +90,9 @@ describe('sendReminderEmail', () => { expect(payload.deliveryTargets.email.subject).toBe('[Due Soon] Task due today') }) - it('omits recipientCompanyId when null', async () => { + it('resolves recipientCompanyId when null', async () => { const createNotification = jest.fn().mockResolvedValue({ id: 'notif_3', createdAt: '2026-05-25T00:00:00Z' }) + const getClient = jest.fn().mockResolvedValue({ companyId: 'company_resolved' }) await sendReminderEmail({ task, @@ -99,11 +101,12 @@ describe('sendReminderEmail', () => { reminderType: TaskReminderType.NO_DUE_DATE_3D, isCompanyRecipient: false, workspace, - copilot: buildCopilotMock(createNotification), + copilot: buildCopilotMock(createNotification, getClient), }) const payload = createNotification.mock.calls[0][0] - expect(payload.recipientCompanyId).toBeUndefined() + expect(getClient).toHaveBeenCalledWith('client_1') + expect(payload.recipientCompanyId).toBe('company_resolved') }) it('propagates errors from Copilot (no ledger compensation here)', async () => { diff --git a/src/jobs/notifications/send-reminder-email.ts b/src/jobs/notifications/send-reminder-email.ts index dcbdaae04..378ebafe1 100644 --- a/src/jobs/notifications/send-reminder-email.ts +++ b/src/jobs/notifications/send-reminder-email.ts @@ -5,6 +5,7 @@ import { reminderSubjectOverrideWorkspaces, reminderSubjectReplacement, reminder import { NotificationRequestBody, WorkspaceResponse } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' import { Task, TaskReminderType } from '@prisma/client' +import { resolveRecipientCompanyId } from './resolve-recipient-company' export type SendReminderEmailArgs = { task: Pick @@ -54,12 +55,13 @@ export const sendReminderEmail = async ({ title: details.title, body: details.body, } + const resolvedRecipientCompanyId = await resolveRecipientCompanyId({ copilot, recipientClientId, recipientCompanyId }) const payload: NotificationRequestBody = { senderId: task.createdById, senderType: 'internalUser', recipientClientId, - recipientCompanyId: recipientCompanyId ?? undefined, + recipientCompanyId: resolvedRecipientCompanyId, deliveryTargets: { email }, }