Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
30 changes: 25 additions & 5 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 mockGetClient = 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,
getClient: mockGetClient,
createNotification: mockCreateNotification,
})),
}))

jest.mock('./send-grouped-email', () => ({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 })])

Expand Down Expand Up @@ -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
})

Expand Down
18 changes: 14 additions & 4 deletions src/jobs/notifications/flush-grouped-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,13 +61,22 @@ const resolveSenderId = async (copilot: CopilotAPI): Promise<string> => {
return senderId
}

const sendIndividualEmail = async (copilot: CopilotAPI, payload: NotificationRequestBody): Promise<void> => {
const sendIndividualEmail = async ({
copilot,
payload,
recipientCompanyId,
}: {
copilot: CopilotAPI
payload: NotificationRequestBody
recipientCompanyId: string | null
}): Promise<void> => {
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
}
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions src/jobs/notifications/resolve-recipient-company.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<NotificationRequestBody> => {
if (!payload.recipientClientId || payload.recipientCompanyId) return payload

return {
...payload,
recipientCompanyId: await resolveRecipientCompanyId({
copilot,
recipientClientId: payload.recipientClientId,
recipientCompanyId,
}),
}
}
11 changes: 7 additions & 4 deletions src/jobs/notifications/send-grouped-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion src/jobs/notifications/send-grouped-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,12 +22,13 @@ export const sendGroupedEmail = async ({
copilot,
}: SendGroupedEmailArgs): Promise<string> => {
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,
Expand Down
59 changes: 59 additions & 0 deletions src/jobs/notifications/send-grouped-reminder-email.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
4 changes: 3 additions & 1 deletion src/jobs/notifications/send-grouped-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -20,12 +21,13 @@ export const sendGroupedReminderEmail = async ({
copilot,
}: SendGroupedReminderEmailArgs): Promise<string> => {
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,
Expand Down
11 changes: 7 additions & 4 deletions src/jobs/notifications/send-reminder-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion src/jobs/notifications/send-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task, 'id' | 'title' | 'createdById'>
Expand Down Expand Up @@ -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 },
}

Expand Down
Loading