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
38 changes: 38 additions & 0 deletions src/app/_fetchers/ValidateNotificationCountFetcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getCopilotAppId } from '@/config'
import { ValidateNotificationCountFetcher } from './ValidateNotificationCountFetcher'

jest.mock('@/config', () => ({
apiUrl: 'https://tasks.example.com',
getCopilotAppId: jest.fn(),
}))

const mockGetCopilotAppId = getCopilotAppId as jest.Mock

describe('ValidateNotificationCountFetcher', () => {
const originalFetch = global.fetch

beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn().mockResolvedValue({ ok: true } as Response)
})

afterAll(() => {
global.fetch = originalFetch
})

it('does not call validate-count when Copilot app id is missing', async () => {
mockGetCopilotAppId.mockReturnValue('')

await ValidateNotificationCountFetcher({ token: 'client-token' })

expect(global.fetch).not.toHaveBeenCalled()
})

it('calls validate-count when Copilot app id is configured', async () => {
mockGetCopilotAppId.mockReturnValue('app-id')

await ValidateNotificationCountFetcher({ token: 'client-token' })

expect(global.fetch).toHaveBeenCalledWith('https://tasks.example.com/api/notification/validate-count?token=client-token')
})
})
7 changes: 6 additions & 1 deletion src/app/_fetchers/ValidateNotificationCountFetcher.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { apiUrl } from '@/config'
import { apiUrl, getCopilotAppId } from '@/config'
import { PropsWithToken } from '@/types/interfaces'

export const ValidateNotificationCountFetcher = async ({ token }: PropsWithToken) => {
if (!getCopilotAppId()) {
console.warn('Validate notifications skipped: Copilot app id is not configured')
return <></>
}

try {
await fetch(`${apiUrl}/api/notification/validate-count?token=${token}`)
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MAX_NOTIFICATIONS_COUNT } from '@/constants/notifications'
import { getCopilotAppId } from '@/config'
import { DuplicateNotificationsQuerySchema } from '@/types/client-notifications'
import { getArrayDifference } from '@/utils/array'
import { copilotBottleneck } from '@/utils/bottleneck'
Expand All @@ -13,6 +14,11 @@ export class ValidateCountService extends NotificationService {
* @param {string} clientId - Copilot client id for which notification fix has to be done
*/
async fixClientNotificationCount(clientId: string, companyId: string, workspaceId: string): Promise<void> {
if (!getCopilotAppId()) {
console.warn('ValidateCount :: Skipping notification count validation because Copilot app id is not configured')
return
}

const notifications = await this.copilot.getClientNotifications(clientId, companyId, workspaceId, {
limit: MAX_NOTIFICATIONS_COUNT,
})
Expand Down
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const supabaseBucket = process.env.NEXT_PUBLIC_SUPABASE_BUCKET || ''
// (OUT-3864). Empty falls back to the project URL, so behaviour is unchanged until it's configured.
export const supabaseStorageDomain = process.env.NEXT_PUBLIC_SUPABASE_STORAGE_DOMAIN || ''
export const cronSecret = process.env.CRON_SECRET || ''
export const APP_ID = process.env.COPILOT_APP_API_KEY
export const getCopilotAppId = () => process.env.COPILOT_APP_ID || process.env.COPILOT_APP_API_KEY || ''
export const APP_ID = getCopilotAppId()

export const ScrapImageExpiryPeriod = +(process.env.SCRAP_IMAGE_EXPIRY_PERIOD || '604800000')

Expand Down
84 changes: 84 additions & 0 deletions src/utils/CopilotAPI.notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const ORIGINAL_ENV = process.env

const APP_ID = '00000000-0000-4000-8000-000000000001'
const OTHER_APP_ID = '00000000-0000-4000-8000-000000000002'

const loadCopilotAPI = async () => {
jest.resetModules()
jest.doMock('@/app/api/core/utils/withRetry', () => ({
withRetry: (fn: (...args: unknown[]) => unknown, args: unknown[]) => fn(...args),
}))
jest.doMock('copilot-node-sdk', () => ({
copilotApi: jest.fn(() => ({})),
}))

return await import('@/utils/CopilotAPI')
}

const notification = (overrides: Record<string, unknown>) => ({
id: 'notification-id',
appId: APP_ID,
createdAt: '2026-06-24T00:00:00.000Z',
...overrides,
})

describe('CopilotAPI client notification filtering', () => {
beforeEach(() => {
process.env = {
...ORIGINAL_ENV,
COPILOT_API_KEY: 'api-key',
NEXT_PUBLIC_ASSEMBLY_API_DOMAIN: 'https://api.example.com',
}
delete process.env.COPILOT_APP_ID
delete process.env.COPILOT_APP_API_KEY
})

afterEach(() => {
jest.dontMock('@/app/api/core/utils/withRetry')
jest.dontMock('copilot-node-sdk')
})

afterAll(() => {
process.env = ORIGINAL_ENV
})

it('skips notification lookup when no Copilot app id is configured', async () => {
const { CopilotAPI } = await loadCopilotAPI()
const copilot = new CopilotAPI('token')
const manualFetch = jest.fn()
copilot.manualFetch = manualFetch

const result = await copilot.getClientNotifications('client-1', 'company-1', 'workspace-1')

expect(result).toEqual([])
expect(manualFetch).not.toHaveBeenCalled()
})

it('filters Copilot notifications to the configured app and company', async () => {
process.env.COPILOT_APP_ID = APP_ID
const { CopilotAPI } = await loadCopilotAPI()
const copilot = new CopilotAPI('token')
const manualFetch = jest.fn().mockResolvedValue({
data: [
notification({ id: 'matching-recipient-company', recipientCompanyId: 'company-1' }),
notification({ id: 'matching-task-company', companyId: 'company-1' }),
notification({ id: 'other-app', appId: OTHER_APP_ID, recipientCompanyId: 'company-1' }),
notification({ id: 'other-company', recipientCompanyId: 'company-2' }),
],
})
copilot.manualFetch = manualFetch

const result = await copilot.getClientNotifications('client-1', 'company-1', 'workspace-1', { limit: 25 })

expect(manualFetch).toHaveBeenCalledWith(
'notifications',
{
recipientClientId: 'client-1',
recipientCompanyId: 'company-1',
limit: '25',
},
'workspace-1',
)
expect(result.map(({ id }) => id)).toEqual(['matching-recipient-company', 'matching-task-company'])
})
})
10 changes: 8 additions & 2 deletions src/utils/CopilotAPI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import APIError from '@/app/api/core/exceptions/api'
import { withRetry } from '@/app/api/core/utils/withRetry'
import { copilotAPIKey as apiKey, APP_ID, assemblyApiDomain } from '@/config'
import { copilotAPIKey as apiKey, assemblyApiDomain, getCopilotAppId } from '@/config'
import { MAX_LIMIT_CLIENT_COUNT } from '@/constants/users'
import {
AssemblyMetadata,
Expand Down Expand Up @@ -292,6 +292,12 @@ export class CopilotAPI {
} = { limit: 100 },
) {
console.info('CopilotAPI#_getClientNotifications', this.token)
const appId = getCopilotAppId()
if (!appId) {
console.warn('CopilotAPI#_getClientNotifications | Skipping because Copilot app id is not configured')
return []
}

const response = await this.manualFetch(
'notifications',
{
Expand All @@ -304,7 +310,7 @@ export class CopilotAPI {
const notifications = z.array(NotificationCreatedResponseSchema).parse(response.data)
// Return only all notifications triggered by tasks-app
return notifications
.filter((notification) => notification.appId === z.string({ message: 'Missing AppID in environment' }).parse(APP_ID))
.filter((notification) => notification.appId === appId)
.filter((notification) => {
const isSameRecipientCompanyId =
notification.recipientCompanyId && notification.recipientCompanyId === recipientCompanyId
Expand Down
Loading