diff --git a/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..cb28e05b3f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '06fafff7-c722-41d1-869d-9554736f4c53'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-route-path.ts b/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-route-path.ts new file mode 100644 index 0000000000..7753b4eb64 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/reconcile-upcoming-calendar-events-route-path.ts @@ -0,0 +1,2 @@ +export const RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH = + '/call-recorder/reconcile-upcoming-calendar-events'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts deleted file mode 100644 index a54103eb3a..0000000000 --- a/packages/twenty-apps/public/call-recorder/src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = - '53e0acb4-b761-40c9-8aaf-554d2a5da00f'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/start-post-install-backfills-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/start-post-install-backfills-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..8aa18debf4 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/start-post-install-backfills-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '53e0acb4-b761-40c9-8aaf-554d2a5da00f'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/sweep-upcoming-calendar-events-logic-function-universal-identifier.ts b/packages/twenty-apps/public/call-recorder/src/constants/sweep-upcoming-calendar-events-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..9187ad5f49 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/sweep-upcoming-calendar-events-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '05ab984e-c6ad-4b1c-800f-8f029dc7aac8'; diff --git a/packages/twenty-apps/public/call-recorder/src/constants/twenty-functions-url-env-var-name.ts b/packages/twenty-apps/public/call-recorder/src/constants/twenty-functions-url-env-var-name.ts new file mode 100644 index 0000000000..ddd582a4c0 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/constants/twenty-functions-url-env-var-name.ts @@ -0,0 +1 @@ +export const TWENTY_FUNCTIONS_URL_ENV_VAR_NAME = 'TWENTY_FUNCTIONS_URL'; diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts index a79ebf08f8..288a0d39c6 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/__tests__/request-call-recording-summary-generation.test.ts @@ -1,26 +1,26 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; import { requestCallRecordingSummaryGeneration } from 'src/front-components/utils/request-call-recording-summary-generation.util'; const enqueueSnackbarMock = vi.hoisted(() => vi.fn()); const postMock = vi.hoisted(() => vi.fn()); +const restApiClientMock = vi.hoisted(() => vi.fn()); vi.mock('twenty-sdk/front-component', () => ({ enqueueSnackbar: enqueueSnackbarMock, })); vi.mock('twenty-client-sdk/rest', () => ({ - RestApiClient: vi.fn(function RestApiClient() { - return { - post: postMock, - }; - }), + RestApiClient: restApiClientMock, })); describe('requestCallRecordingSummaryGeneration', () => { beforeEach(() => { vi.clearAllMocks(); + restApiClientMock.mockImplementation(function RestApiClient() { + return { post: postMock }; + }); postMock.mockResolvedValue({ outcome: 'processed', generatedCallRecordingIds: ['call-recording-1'], @@ -29,6 +29,26 @@ describe('requestCallRecordingSummaryGeneration', () => { }); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('posts to the injected functions origin without the legacy prefix', async () => { + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + + await requestCallRecordingSummaryGeneration({ + calendarEventIds: ['calendar-event-1'], + }); + + expect(restApiClientMock).toHaveBeenCalledWith({ + baseUrl: 'https://acme.functions.example.com', + }); + expect(postMock).toHaveBeenCalledWith( + GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + { calendarEventIds: ['calendar-event-1'] }, + ); + }); + it('does nothing when no calendar events are selected', async () => { await requestCallRecordingSummaryGeneration({ calendarEventIds: [] }); diff --git a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts index 03ac72909d..5a2f68488d 100644 --- a/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/front-components/utils/request-call-recording-summary-generation.util.ts @@ -1,7 +1,9 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { RestApiClient } from 'twenty-client-sdk/rest'; import { enqueueSnackbar } from 'twenty-sdk/front-component'; import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; +import { TWENTY_FUNCTIONS_URL_ENV_VAR_NAME } from 'src/constants/twenty-functions-url-env-var-name'; type GenerateSummariesResponse = { outcome?: string; @@ -64,10 +66,17 @@ export const requestCallRecordingSummaryGeneration = async ({ } try { - const client = new RestApiClient(); + // The host injects the isolated functions origin; the legacy /s route + // 410s post-cutoff functions and only remains for self-hosting. + const functionsBaseUrl = process.env[TWENTY_FUNCTIONS_URL_ENV_VAR_NAME]; + const client = isNonEmptyString(functionsBaseUrl) + ? new RestApiClient({ baseUrl: functionsBaseUrl }) + : new RestApiClient(); const response = await client.post( - `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, + isNonEmptyString(functionsBaseUrl) + ? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH + : `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, { calendarEventIds }, ); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts new file mode 100644 index 0000000000..3510a9a56a --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/reconcile-upcoming-calendar-events.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type RoutePayload } from 'twenty-sdk/define'; + +import routeLogicFunction, { + reconcileUpcomingCalendarEventsHandler, +} from 'src/logic-functions/reconcile-upcoming-calendar-events'; + +const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn()); +const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock( + 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util', + () => ({ + fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock, + }), +); + +vi.mock( + 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util', + () => ({ + reconcileUpcomingCalendarEventBatches: + reconcileUpcomingCalendarEventBatchesMock, + }), +); + +const buildRoutePayload = ( + body: object | null, +): RoutePayload<{ calendarEventIds?: string[] }> => + ({ + body, + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + isBase64Encoded: false, + rawBody: undefined, + requestContext: { http: { method: 'POST', path: '/' } }, + userWorkspaceId: null, + }) as never; + +const BATCH_RESULT = { + reconciledCalendarEventIds: ['calendar-event-1'], + failedCalendarEventIds: [], + remainingCalendarEventIds: [], + actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 }, + continuationRequested: false, +}; + +describe('reconcileUpcomingCalendarEventsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]); + reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT); + }); + + it('is configured as an authenticated route with a self-invokable timeout', () => { + expect(routeLogicFunction.config).toEqual( + expect.objectContaining({ + name: 'reconcile-upcoming-calendar-events', + timeoutSeconds: 900, + httpRouteTriggerSettings: { + path: '/call-recorder/reconcile-upcoming-calendar-events', + httpMethod: 'POST', + isAuthRequired: true, + }, + }), + ); + }); + + it('processes explicit calendar event ids without sweeping', async () => { + const result = await reconcileUpcomingCalendarEventsHandler( + buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }), + ); + + expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( + expect.objectContaining({ calendarEventIds: ['calendar-event-1'] }), + ); + expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled(); + }); + + it('sweeps upcoming calendar events when no ids are given', async () => { + fetchUpcomingCalendarEventIdsMock.mockResolvedValue([ + 'calendar-event-1', + 'calendar-event-2', + ]); + + const result = await reconcileUpcomingCalendarEventsHandler( + buildRoutePayload(null), + ); + + expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith( + expect.anything(), + expect.any(Date), + ); + expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + }), + ); + expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + }); + + it('short-circuits an empty sweep without running batches', async () => { + const result = await reconcileUpcomingCalendarEventsHandler( + buildRoutePayload({}), + ); + + expect(result).toEqual({ outcome: 'nothing-to-reconcile' }); + expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + }); + + it('does not sweep when an empty calendar event selection is sent', async () => { + const result = await reconcileUpcomingCalendarEventsHandler( + buildRoutePayload({ calendarEventIds: [] }), + ); + + expect(result).toEqual({ outcome: 'nothing-selected' }); + expect(fetchUpcomingCalendarEventIdsMock).not.toHaveBeenCalled(); + expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + }); + + it('passes a deadline that reserves time for the continuation request', async () => { + await reconcileUpcomingCalendarEventsHandler( + buildRoutePayload({ calendarEventIds: ['calendar-event-1'] }), + ); + + const { deadlineAtMs } = + reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0]; + + expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000); + expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts deleted file mode 100644 index 791b95f387..0000000000 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-call-recording-summary-backfill-on-install.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import postInstallLogicFunction, { - startCallRecordingSummaryBackfillOnInstallHandler, -} from 'src/logic-functions/start-call-recording-summary-backfill-on-install'; - -const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn()); - -vi.mock( - 'src/logic-functions/data/request-call-recording-summaries-backfill.util', - () => ({ - requestCallRecordingSummariesBackfill: - requestCallRecordingSummariesBackfillMock, - }), -); - -describe('start-call-recording-summary-backfill-on-install', () => { - beforeEach(() => { - vi.clearAllMocks(); - requestCallRecordingSummariesBackfillMock.mockResolvedValue(true); - }); - - it('is configured to run on app version upgrades', () => { - expect(postInstallLogicFunction.config).toEqual( - expect.objectContaining({ - name: 'start-call-recording-summary-backfill-on-install', - timeoutSeconds: 30, - shouldRunOnVersionUpgrade: true, - }), - ); - }); - - it('skips fresh installs', async () => { - const result = await startCallRecordingSummaryBackfillOnInstallHandler({ - newVersion: '1.0.6', - }); - - expect(result).toEqual({ outcome: 'skipped-initial-install' }); - expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled(); - }); - - it('requests backfill on version upgrades', async () => { - const result = await startCallRecordingSummaryBackfillOnInstallHandler({ - previousVersion: '1.0.5', - newVersion: '1.0.6', - }); - - expect(result).toEqual({ outcome: 'backfill-requested' }); - expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1); - }); - - it('reports failed backfill kickoff requests', async () => { - requestCallRecordingSummariesBackfillMock.mockResolvedValue(false); - - const result = await startCallRecordingSummaryBackfillOnInstallHandler({ - previousVersion: '1.0.5', - newVersion: '1.0.6', - }); - - expect(result).toEqual({ outcome: 'backfill-request-failed' }); - }); -}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts new file mode 100644 index 0000000000..0852d5c327 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/start-post-install-backfills.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import postInstallLogicFunction, { + startPostInstallBackfillsHandler, +} from 'src/logic-functions/start-post-install-backfills'; + +const requestCallRecordingSummariesBackfillMock = vi.hoisted(() => vi.fn()); +const requestUpcomingCalendarEventsReconciliationMock = vi.hoisted(() => + vi.fn(), +); + +vi.mock( + 'src/logic-functions/data/request-call-recording-summaries-backfill.util', + () => ({ + requestCallRecordingSummariesBackfill: + requestCallRecordingSummariesBackfillMock, + }), +); + +vi.mock( + 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util', + () => ({ + requestUpcomingCalendarEventsReconciliation: + requestUpcomingCalendarEventsReconciliationMock, + }), +); + +describe('start-post-install-backfills', () => { + beforeEach(() => { + vi.clearAllMocks(); + requestCallRecordingSummariesBackfillMock.mockResolvedValue(true); + requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(true); + }); + + it('is configured to run on app version upgrades', () => { + expect(postInstallLogicFunction.config).toEqual( + expect.objectContaining({ + name: 'start-post-install-backfills', + timeoutSeconds: 30, + shouldRunOnVersionUpgrade: true, + }), + ); + }); + + it('seeds the sweep and skips summaries on a fresh install', async () => { + const result = await startPostInstallBackfillsHandler({ + newVersion: '1.0.7', + }); + + expect(result).toEqual({ + calendarEventSweepOutcome: 'sweep-requested', + summaryBackfillOutcome: 'skipped-initial-install', + }); + expect( + requestUpcomingCalendarEventsReconciliationMock, + ).toHaveBeenCalledTimes(1); + expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled(); + }); + + it('backfills summaries and skips the sweep on an upgrade', async () => { + const result = await startPostInstallBackfillsHandler({ + previousVersion: '1.0.6', + newVersion: '1.0.7', + }); + + expect(result).toEqual({ + calendarEventSweepOutcome: 'skipped-upgrade', + summaryBackfillOutcome: 'backfill-requested', + }); + expect(requestCallRecordingSummariesBackfillMock).toHaveBeenCalledTimes(1); + expect( + requestUpcomingCalendarEventsReconciliationMock, + ).not.toHaveBeenCalled(); + }); + + it('throws when the fresh-install sweep kickoff fails', async () => { + requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(false); + + await expect( + startPostInstallBackfillsHandler({ newVersion: '1.0.7' }), + ).rejects.toThrow( + 'Failed to start post-install backfills: upcoming calendar event sweep', + ); + expect(requestCallRecordingSummariesBackfillMock).not.toHaveBeenCalled(); + }); + + it('throws when the upgrade summary backfill kickoff fails', async () => { + requestCallRecordingSummariesBackfillMock.mockResolvedValue(false); + + await expect( + startPostInstallBackfillsHandler({ + previousVersion: '1.0.6', + newVersion: '1.0.7', + }), + ).rejects.toThrow( + 'Failed to start post-install backfills: call recording summary backfill', + ); + expect( + requestUpcomingCalendarEventsReconciliationMock, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts new file mode 100644 index 0000000000..234ece8c2f --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/__tests__/sweep-upcoming-calendar-events.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import sweepLogicFunction, { + sweepUpcomingCalendarEventsHandler, +} from 'src/logic-functions/sweep-upcoming-calendar-events'; + +const fetchUpcomingCalendarEventIdsMock = vi.hoisted(() => vi.fn()); +const reconcileUpcomingCalendarEventBatchesMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +vi.mock( + 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util', + () => ({ + fetchUpcomingCalendarEventIds: fetchUpcomingCalendarEventIdsMock, + }), +); + +vi.mock( + 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util', + () => ({ + reconcileUpcomingCalendarEventBatches: + reconcileUpcomingCalendarEventBatchesMock, + }), +); + +const BATCH_RESULT = { + reconciledCalendarEventIds: ['calendar-event-1'], + failedCalendarEventIds: [], + remainingCalendarEventIds: [], + actionCounts: { created: 1, updated: 0, canceled: 0, skipped: 0, failed: 0 }, + continuationRequested: false, +}; + +describe('sweepUpcomingCalendarEventsHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchUpcomingCalendarEventIdsMock.mockResolvedValue([]); + reconcileUpcomingCalendarEventBatchesMock.mockResolvedValue(BATCH_RESULT); + }); + + it('is configured as a daily cron with a self-invokable timeout', () => { + expect(sweepLogicFunction.config).toEqual( + expect.objectContaining({ + name: 'sweep-upcoming-calendar-events', + timeoutSeconds: 900, + cronTriggerSettings: { pattern: '0 4 * * *' }, + }), + ); + }); + + it('reconciles every upcoming calendar event within the horizon', async () => { + fetchUpcomingCalendarEventIdsMock.mockResolvedValue([ + 'calendar-event-1', + 'calendar-event-2', + ]); + + const result = await sweepUpcomingCalendarEventsHandler(); + + expect(fetchUpcomingCalendarEventIdsMock).toHaveBeenCalledWith( + expect.anything(), + expect.any(Date), + ); + expect(reconcileUpcomingCalendarEventBatchesMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + }), + ); + expect(result).toEqual({ outcome: 'processed', ...BATCH_RESULT }); + }); + + it('short-circuits without running batches when nothing is upcoming', async () => { + const result = await sweepUpcomingCalendarEventsHandler(); + + expect(result).toEqual({ outcome: 'nothing-to-reconcile' }); + expect(reconcileUpcomingCalendarEventBatchesMock).not.toHaveBeenCalled(); + }); + + it('passes a deadline that reserves time for the continuation request', async () => { + fetchUpcomingCalendarEventIdsMock.mockResolvedValue(['calendar-event-1']); + + await sweepUpcomingCalendarEventsHandler(); + + const { deadlineAtMs } = + reconcileUpcomingCalendarEventBatchesMock.mock.calls[0][0]; + + expect(deadlineAtMs).toBeLessThan(Date.now() + 900 * 1000); + expect(deadlineAtMs).toBeGreaterThan(Date.now() + 800 * 1000); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-reconciliation-batch-size.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-reconciliation-batch-size.ts new file mode 100644 index 0000000000..036d5b62b2 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-reconciliation-batch-size.ts @@ -0,0 +1 @@ +export const UPCOMING_CALENDAR_EVENT_RECONCILIATION_BATCH_SIZE = 25; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-scheduling-horizon-days.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-scheduling-horizon-days.ts new file mode 100644 index 0000000000..933e87b93b --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-event-scheduling-horizon-days.ts @@ -0,0 +1,4 @@ +// Recall recommends scheduling bots for a rolling near-term window rather than far in advance, +// so bots carry fresh config and volatile far-future meetings are not scheduled speculatively. +// 7 days matches Recall's "daily sync of the next 7 days" guidance for self-managed calendar integrations. +export const UPCOMING_CALENDAR_EVENT_SCHEDULING_HORIZON_DAYS = 7; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-events-sweep-cron-pattern.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-events-sweep-cron-pattern.ts new file mode 100644 index 0000000000..a81a818981 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/constants/upcoming-calendar-events-sweep-cron-pattern.ts @@ -0,0 +1,2 @@ +// Daily sweep of the scheduling-horizon window, per Recall's daily-sync guidance. +export const UPCOMING_CALENDAR_EVENTS_SWEEP_CRON_PATTERN = '0 4 * * *'; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/fetch-upcoming-calendar-event-ids.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/fetch-upcoming-calendar-event-ids.test.ts new file mode 100644 index 0000000000..ea4125c066 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/fetch-upcoming-calendar-event-ids.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { fetchUpcomingCalendarEventIds } from 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util'; + +const queryMock = vi.fn(); + +const CLIENT: CoreApiClient = Object.assign( + Object.create(CoreApiClient.prototype), + { + query: queryMock, + mutation: vi.fn(), + }, +); + +const NOW = new Date('2026-07-04T12:00:00.000Z'); + +const buildPage = ( + calendarEventIds: string[], + { hasNextPage = false, endCursor = null as string | null } = {}, +) => ({ + calendarEvents: { + pageInfo: { hasNextPage, endCursor }, + edges: calendarEventIds.map((calendarEventId) => ({ + node: { id: calendarEventId }, + })), + }, +}); + +describe('fetchUpcomingCalendarEventIds', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters to non-canceled events starting within the scheduling horizon and not yet ended, closest first', async () => { + queryMock.mockResolvedValue(buildPage(['calendar-event-1'])); + + await fetchUpcomingCalendarEventIds(CLIENT, NOW); + + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + calendarEvents: expect.objectContaining({ + __args: expect.objectContaining({ + filter: { + isCanceled: { eq: false }, + or: [ + { + and: [ + { startsAt: { lte: '2026-07-11T12:00:00.000Z' } }, + { endsAt: { gt: '2026-07-04T12:00:00.000Z' } }, + ], + }, + { + and: [ + { endsAt: { is: 'NULL' } }, + { startsAt: { gt: '2026-07-04T12:00:00.000Z' } }, + { startsAt: { lte: '2026-07-11T12:00:00.000Z' } }, + ], + }, + ], + }, + orderBy: [{ startsAt: 'AscNullsLast' }], + }), + }), + }), + ); + }); + + it('pages through every result and returns unique sorted ids', async () => { + queryMock + .mockResolvedValueOnce( + buildPage(['calendar-event-2', 'calendar-event-1'], { + hasNextPage: true, + endCursor: 'cursor-1', + }), + ) + .mockResolvedValueOnce( + buildPage(['calendar-event-3', 'calendar-event-2']), + ); + + const calendarEventIds = await fetchUpcomingCalendarEventIds(CLIENT, NOW); + + expect(queryMock).toHaveBeenCalledTimes(2); + expect(queryMock.mock.calls[1][0].calendarEvents.__args.after).toBe( + 'cursor-1', + ); + expect(calendarEventIds).toEqual([ + 'calendar-event-1', + 'calendar-event-2', + 'calendar-event-3', + ]); + }); + + it('returns an empty list when no upcoming events exist', async () => { + queryMock.mockResolvedValue(buildPage([])); + + await expect(fetchUpcomingCalendarEventIds(CLIENT, NOW)).resolves.toEqual( + [], + ); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts new file mode 100644 index 0000000000..c8e93de959 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/post-to-own-route.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; + +const postMock = vi.hoisted(() => vi.fn()); +const restApiClientMock = vi.hoisted(() => vi.fn()); +const resolveOwnRouteBaseUrlMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/rest', () => ({ + RestApiClient: restApiClientMock, +})); + +vi.mock('src/logic-functions/data/resolve-own-route-base-url.util', () => ({ + resolveOwnRouteBaseUrl: resolveOwnRouteBaseUrlMock, +})); + +describe('postToOwnRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + restApiClientMock.mockImplementation(function RestApiClient() { + return { post: postMock }; + }); + postMock.mockResolvedValue({}); + resolveOwnRouteBaseUrlMock.mockReturnValue( + 'https://acme.functions.example.com', + ); + }); + + it('posts to the functions origin when resolved', async () => { + const result = await postToOwnRoute({ + path: '/call-recorder/some-route', + body: { key: 'value' }, + }); + + expect(result).toBe(true); + expect(restApiClientMock).toHaveBeenCalledWith({ + baseUrl: 'https://acme.functions.example.com', + }); + expect(postMock).toHaveBeenCalledWith( + '/call-recorder/some-route', + { key: 'value' }, + { signal: expect.any(AbortSignal) }, + ); + }); + + it('treats timeout as a successfully flushed request', async () => { + const timeoutError = new Error('Timed out'); + timeoutError.name = 'TimeoutError'; + postMock.mockRejectedValue(timeoutError); + + await expect( + postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), + ).resolves.toBe(true); + }); + + it('returns false when the request fails before flushing', async () => { + postMock.mockRejectedValue(new Error('Network failed')); + + await expect( + postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), + ).resolves.toBe(false); + }); + + it('returns false when the route base url cannot be resolved', async () => { + resolveOwnRouteBaseUrlMock.mockImplementation(() => { + throw new Error('Unable to resolve target'); + }); + + await expect( + postToOwnRoute({ path: '/call-recorder/some-route', body: {} }), + ).resolves.toBe(false); + expect(restApiClientMock).not.toHaveBeenCalled(); + expect(postMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts index 2446bc101e..b096af371d 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-call-recording-summaries-backfill.test.ts @@ -3,43 +3,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util'; -const postMock = vi.hoisted(() => vi.fn()); +const postToOwnRouteMock = vi.hoisted(() => vi.fn()); -vi.mock('twenty-client-sdk/rest', () => ({ - RestApiClient: vi.fn(function RestApiClient() { - return { - post: postMock, - }; - }), +vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({ + postToOwnRoute: postToOwnRouteMock, })); describe('requestCallRecordingSummariesBackfill', () => { beforeEach(() => { vi.clearAllMocks(); - postMock.mockResolvedValue({}); + postToOwnRouteMock.mockResolvedValue(true); }); it('posts an empty body to the summary generation route', async () => { const result = await requestCallRecordingSummariesBackfill(); expect(result).toBe(true); - expect(postMock).toHaveBeenCalledWith( - `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, - {}, - { signal: expect.any(AbortSignal) }, - ); + expect(postToOwnRouteMock).toHaveBeenCalledWith({ + path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + body: {}, + }); }); - it('treats timeout as a successfully flushed request', async () => { - const timeoutError = new Error('Timed out'); - timeoutError.name = 'TimeoutError'; - postMock.mockRejectedValue(timeoutError); - - await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(true); - }); - - it('returns false when the kickoff request fails before flushing', async () => { - postMock.mockRejectedValue(new Error('Network failed')); + it('reports a kickoff that failed to fire', async () => { + postToOwnRouteMock.mockResolvedValue(false); await expect(requestCallRecordingSummariesBackfill()).resolves.toBe(false); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts new file mode 100644 index 0000000000..78da918dfe --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/request-upcoming-calendar-events-reconciliation.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; +import { requestUpcomingCalendarEventsReconciliation } from 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util'; + +const postToOwnRouteMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/data/post-to-own-route.util', () => ({ + postToOwnRoute: postToOwnRouteMock, +})); + +describe('requestUpcomingCalendarEventsReconciliation', () => { + beforeEach(() => { + vi.clearAllMocks(); + postToOwnRouteMock.mockResolvedValue(true); + }); + + it('posts an empty body to start a full sweep', async () => { + const result = await requestUpcomingCalendarEventsReconciliation(); + + expect(result).toBe(true); + expect(postToOwnRouteMock).toHaveBeenCalledWith({ + path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + body: {}, + }); + }); + + it('posts the remaining calendar event ids to continue a sweep', async () => { + await requestUpcomingCalendarEventsReconciliation({ + calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + }); + + expect(postToOwnRouteMock).toHaveBeenCalledWith({ + path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + body: { calendarEventIds: ['calendar-event-1', 'calendar-event-2'] }, + }); + }); + + it('reports a kickoff that failed to fire', async () => { + postToOwnRouteMock.mockResolvedValue(false); + + await expect(requestUpcomingCalendarEventsReconciliation()).resolves.toBe( + false, + ); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/resolve-own-route-base-url.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/resolve-own-route-base-url.test.ts new file mode 100644 index 0000000000..4d5761e133 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/__tests__/resolve-own-route-base-url.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveOwnRouteBaseUrl } from 'src/logic-functions/data/resolve-own-route-base-url.util'; + +describe('resolveOwnRouteBaseUrl', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns the injected functions url', () => { + vi.stubEnv('TWENTY_FUNCTIONS_URL', 'https://acme.functions.example.com'); + + expect(resolveOwnRouteBaseUrl()).toBe('https://acme.functions.example.com'); + }); + + it('fails clearly when the functions url is not injected', () => { + vi.stubEnv('TWENTY_FUNCTIONS_URL', ''); + + expect(() => resolveOwnRouteBaseUrl()).toThrow( + 'Unable to resolve Call Recorder own route target without TWENTY_FUNCTIONS_URL', + ); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/create-call-recording.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/create-call-recording.util.ts index c8ed9a95f5..208361c7ea 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/create-call-recording.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/create-call-recording.util.ts @@ -5,7 +5,7 @@ import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/c import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; export type ScheduledCallRecordingFields = { - title: string | null; + title: string; status: CallRecordingStatus.SCHEDULED; recordingRequestStatus: CallRecordingRequestStatus.REQUESTED; calendarEventId: string; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-upcoming-calendar-event-ids.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-upcoming-calendar-event-ids.util.ts new file mode 100644 index 0000000000..2b9f9aa91e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/fetch-upcoming-calendar-event-ids.util.ts @@ -0,0 +1,75 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { + fetchAllNodes, + type ConnectionPage, +} from 'src/logic-functions/data/fetch-all-nodes.util'; +import { computeUpcomingCalendarEventHorizonEnd } from 'src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; + +type CalendarEventIdNode = { + id: string; +}; + +export const fetchUpcomingCalendarEventIds = async ( + client: CoreApiClient, + now: Date, +): Promise => { + const nowIsoString = now.toISOString(); + const horizonEndIsoString = + computeUpcomingCalendarEventHorizonEnd(now).toISOString(); + + const calendarEventNodes = await fetchAllNodes( + async (afterCursor) => { + const queryResult = await client.query({ + calendarEvents: { + __args: { + filter: { + isCanceled: { eq: false }, + // Mirror the policy: the horizon is measured from startsAt (the bot's join time), + // while endsAt drives the not-past check so in-progress meetings still qualify. + // Ranges use and-ed entries because the API applies one operator per field filter. + or: [ + { + and: [ + { startsAt: { lte: horizonEndIsoString } }, + { endsAt: { gt: nowIsoString } }, + ], + }, + { + and: [ + { endsAt: { is: 'NULL' } }, + { startsAt: { gt: nowIsoString } }, + { startsAt: { lte: horizonEndIsoString } }, + ], + }, + ], + }, + orderBy: [{ startsAt: 'AscNullsLast' }], + first: TWENTY_PAGE_SIZE, + ...(isUndefined(afterCursor) ? {} : { after: afterCursor }), + }, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + }, + }, + }, + }); + + return queryResult.calendarEvents as + | ConnectionPage + | undefined; + }, + ); + + return getUniqueSortedIds( + calendarEventNodes.map((calendarEvent) => calendarEvent.id), + ); +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/post-to-own-route.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/post-to-own-route.util.ts new file mode 100644 index 0000000000..9445f41a22 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/post-to-own-route.util.ts @@ -0,0 +1,40 @@ +import { RestApiClient } from 'twenty-client-sdk/rest'; + +import { resolveOwnRouteBaseUrl } from 'src/logic-functions/data/resolve-own-route-base-url.util'; + +const OWN_ROUTE_FLUSH_MS = 5_000; + +// Fire-and-forget POST to one of this app's own HTTP routes; a timeout only +// means the request was flushed, not that the target run failed. +export const postToOwnRoute = async ({ + path, + body, +}: { + path: string; + body: object; +}): Promise => { + try { + const client = new RestApiClient({ baseUrl: resolveOwnRouteBaseUrl() }); + + await client.post(path, body, { + signal: AbortSignal.timeout(OWN_ROUTE_FLUSH_MS), + }); + + return true; + } catch (error) { + if ( + error instanceof Error && + (error.name === 'TimeoutError' || error.name === 'AbortError') + ) { + return true; + } + + if (process.env.NODE_ENV !== 'test') { + console.error( + `[call-recorder] request to own route ${path} failed to fire: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return false; + } +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts index 15a2e4b669..27b5f42faf 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-backfill.util.ts @@ -1,35 +1,8 @@ -import { RestApiClient } from 'twenty-client-sdk/rest'; - import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; +import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; -const BACKFILL_KICKOFF_FLUSH_MS = 5_000; - -export const requestCallRecordingSummariesBackfill = - async (): Promise => { - const client = new RestApiClient(); - - try { - await client.post( - `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, - {}, - { signal: AbortSignal.timeout(BACKFILL_KICKOFF_FLUSH_MS) }, - ); - - return true; - } catch (error) { - if ( - error instanceof Error && - (error.name === 'TimeoutError' || error.name === 'AbortError') - ) { - return true; - } - - if (process.env.NODE_ENV !== 'test') { - console.error( - `[call-recorder] summary backfill kickoff failed to fire: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - return false; - } - }; +export const requestCallRecordingSummariesBackfill = async (): Promise => + postToOwnRoute({ + path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + body: {}, + }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts index 17787ca416..93043b6d5e 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-call-recording-summaries-continuation.util.ts @@ -1,38 +1,12 @@ -import { RestApiClient } from 'twenty-client-sdk/rest'; - import { GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH } from 'src/constants/generate-call-recording-summaries-route-path'; - -const CONTINUATION_FLUSH_MS = 5_000; +import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; export const requestCallRecordingSummariesContinuation = async ({ callRecordingIds, }: { callRecordingIds: string[]; -}): Promise => { - const client = new RestApiClient(); - - try { - await client.post( - `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`, - { callRecordingIds }, - { signal: AbortSignal.timeout(CONTINUATION_FLUSH_MS) }, - ); - - return true; - } catch (error) { - if ( - error instanceof Error && - (error.name === 'TimeoutError' || error.name === 'AbortError') - ) { - return true; - } - - if (process.env.NODE_ENV !== 'test') { - console.error( - `[call-recorder] summary generation continuation failed to fire: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - return false; - } -}; +}): Promise => + postToOwnRoute({ + path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH, + body: { callRecordingIds }, + }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util.ts new file mode 100644 index 0000000000..68ee389541 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util.ts @@ -0,0 +1,14 @@ +import { isUndefined } from '@sniptt/guards'; + +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; +import { postToOwnRoute } from 'src/logic-functions/data/post-to-own-route.util'; + +export const requestUpcomingCalendarEventsReconciliation = async ({ + calendarEventIds, +}: { + calendarEventIds?: string[]; +} = {}): Promise => + postToOwnRoute({ + path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + body: isUndefined(calendarEventIds) ? {} : { calendarEventIds }, + }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/data/resolve-own-route-base-url.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/resolve-own-route-base-url.util.ts new file mode 100644 index 0000000000..d8491ccf3d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/data/resolve-own-route-base-url.util.ts @@ -0,0 +1,14 @@ +import { TWENTY_FUNCTIONS_URL_ENV_VAR_NAME } from 'src/constants/twenty-functions-url-env-var-name'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const resolveOwnRouteBaseUrl = (): string => { + const injectedFunctionsUrl = process.env[TWENTY_FUNCTIONS_URL_ENV_VAR_NAME]; + + if (!isNonEmptyString(injectedFunctionsUrl)) { + throw new Error( + `Unable to resolve Call Recorder own route target without ${TWENTY_FUNCTIONS_URL_ENV_VAR_NAME}`, + ); + } + + return injectedFunctionsUrl; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts index 3fc2679cf8..b7ad56cbf3 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts @@ -8,6 +8,9 @@ const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z'; const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z'; const PAST_STARTS_AT = '2026-01-01T09:00:00.000Z'; const PAST_ENDS_AT = '2026-01-01T10:00:00.000Z'; +// The scheduling horizon is 7 days, so these fall beyond it. +const BEYOND_HORIZON_STARTS_AT = '2026-01-10T13:00:00.000Z'; +const BEYOND_HORIZON_ENDS_AT = '2026-01-10T14:00:00.000Z'; describe('resolveCallRecorderPolicyResult', () => { it('requires a bot when preference is ON and the event is upcoming', () => { @@ -117,4 +120,58 @@ describe('resolveCallRecorderPolicyResult', () => { reason: 'EVENT_CANCELED', }); }); + + it('does not request a bot for an event beyond the scheduling horizon', () => { + expect( + resolveCallRecorderPolicyResult({ + input: { + callRecorderPreference: CallRecorderPreference.ON, + isCanceled: false, + startsAt: BEYOND_HORIZON_STARTS_AT, + endsAt: BEYOND_HORIZON_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: false, + reason: 'EVENT_BEYOND_SCHEDULING_HORIZON', + }); + }); + + it('requires a bot for a long meeting that starts within the horizon but ends beyond it', () => { + expect( + resolveCallRecorderPolicyResult({ + input: { + callRecorderPreference: CallRecorderPreference.ON, + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: BEYOND_HORIZON_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: true, + reason: 'RECORDING_ENABLED', + }); + }); + + it('falls back to endsAt for the horizon when startsAt is an empty string', () => { + expect( + resolveCallRecorderPolicyResult({ + input: { + callRecorderPreference: CallRecorderPreference.ON, + isCanceled: false, + startsAt: '', + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: true, + reason: 'RECORDING_ENABLED', + }); + }); }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recording-title.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recording-title.test.ts new file mode 100644 index 0000000000..b5e2cc1dde --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/__tests__/resolve-call-recording-title.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCallRecordingTitle } from 'src/logic-functions/domain/resolve-call-recording-title.util'; + +describe('resolveCallRecordingTitle', () => { + it('uses a visible calendar event title', () => { + expect( + resolveCallRecordingTitle({ + title: ' Customer Sync ', + startsAt: '2026-01-01T13:00:00.000Z', + }), + ).toBe('Customer Sync'); + }); + + it('falls back to the calendar event start time when the title is unavailable', () => { + expect( + resolveCallRecordingTitle({ + title: undefined, + startsAt: '2026-01-01T13:00:00.000Z', + }), + ).toBe('Call recording - Jan 1, 2026, 1:00 PM UTC'); + }); + + it('uses a generic fallback when the title and start time are unavailable', () => { + expect( + resolveCallRecordingTitle({ + title: undefined, + startsAt: undefined, + }), + ).toBe('Call recording'); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util.ts new file mode 100644 index 0000000000..dcde409f9c --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util.ts @@ -0,0 +1,11 @@ +import { UPCOMING_CALENDAR_EVENT_SCHEDULING_HORIZON_DAYS } from 'src/logic-functions/constants/upcoming-calendar-event-scheduling-horizon-days'; + +export const computeUpcomingCalendarEventHorizonEnd = (now: Date): Date => { + const horizonEnd = new Date(now); + + horizonEnd.setDate( + horizonEnd.getDate() + UPCOMING_CALENDAR_EVENT_SCHEDULING_HORIZON_DAYS, + ); + + return horizonEnd; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts index 46ca58b5d2..a2899c35b2 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts @@ -1,4 +1,7 @@ +import { isUndefined } from '@sniptt/guards'; + import { CallRecorderPreference } from 'src/constants/call-recorder-preference'; +import { computeUpcomingCalendarEventHorizonEnd } from 'src/logic-functions/domain/compute-upcoming-calendar-event-horizon-end.util'; import { type CallRecorderPolicyInput } from 'src/logic-functions/types/call-recorder-policy-input.type'; import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; import { type CallRecorderPolicyNotRequiredReason } from 'src/logic-functions/types/call-recorder-policy-not-required-reason.type'; @@ -36,31 +39,65 @@ export const resolveCallRecorderPolicyResult = ({ return botNotRequired('EVENT_NOT_UPCOMING'); } + if ( + !isCalendarEventWithinSchedulingHorizon({ + startsAt: input.startsAt, + endsAt: input.endsAt, + now, + }) + ) { + return botNotRequired('EVENT_BEYOND_SCHEDULING_HORIZON'); + } + return botRequired('RECORDING_ENABLED'); }; +type CalendarEventWindowInput = { + startsAt: string | undefined; + endsAt: string | undefined; + now: Date; +}; + +const parseTimestampMs = ( + timestamp: string | undefined, +): number | undefined => { + if (!isNonEmptyString(timestamp)) { + return undefined; + } + + const timestampMs = new Date(timestamp).getTime(); + + return Number.isNaN(timestampMs) ? undefined : timestampMs; +}; + const isCalendarEventInFuture = ({ startsAt, endsAt, now, -}: { - startsAt: string | undefined; - endsAt: string | undefined; - now: Date; -}): boolean => { - const reference = endsAt ?? startsAt; +}: CalendarEventWindowInput): boolean => { + const referenceMs = parseTimestampMs(endsAt) ?? parseTimestampMs(startsAt); - if (!isNonEmptyString(reference)) { + if (isUndefined(referenceMs)) { return false; } - const referenceTime = new Date(reference).getTime(); + return referenceMs > now.getTime(); +}; - if (Number.isNaN(referenceTime)) { +// The bot joins at the meeting start, so the horizon is measured from startsAt +// (endsAt only as a fallback), unlike the not-past check which measures from the end. +const isCalendarEventWithinSchedulingHorizon = ({ + startsAt, + endsAt, + now, +}: CalendarEventWindowInput): boolean => { + const referenceMs = parseTimestampMs(startsAt) ?? parseTimestampMs(endsAt); + + if (isUndefined(referenceMs)) { return false; } - return referenceTime > now.getTime(); + return referenceMs <= computeUpcomingCalendarEventHorizonEnd(now).getTime(); }; const botRequired = ( diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recording-title.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recording-title.util.ts new file mode 100644 index 0000000000..6707e234bb --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/domain/resolve-call-recording-title.util.ts @@ -0,0 +1,61 @@ +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const CALL_RECORDING_FALLBACK_TITLE = 'Call recording'; + +const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +export const resolveCallRecordingTitle = ( + calendarEvent: Pick, +): string => { + if (isNonEmptyString(calendarEvent.title)) { + return calendarEvent.title.trim(); + } + + const formattedCalendarEventStartDateTime = + formatCalendarEventStartDateTimeForFallbackTitle(calendarEvent.startsAt); + + return isNonEmptyString(formattedCalendarEventStartDateTime) + ? `${CALL_RECORDING_FALLBACK_TITLE} - ${formattedCalendarEventStartDateTime}` + : CALL_RECORDING_FALLBACK_TITLE; +}; + +const formatCalendarEventStartDateTimeForFallbackTitle = ( + calendarEventStartsAt: string | undefined, +): string | undefined => { + if (!isNonEmptyString(calendarEventStartsAt)) { + return undefined; + } + + const calendarEventStartDate = new Date(calendarEventStartsAt); + + if (Number.isNaN(calendarEventStartDate.getTime())) { + return undefined; + } + + const monthLabel = MONTH_LABELS[calendarEventStartDate.getUTCMonth()]; + const dayOfMonth = calendarEventStartDate.getUTCDate(); + const year = calendarEventStartDate.getUTCFullYear(); + const hourOfDay = calendarEventStartDate.getUTCHours(); + const hourWithinHalfDay = hourOfDay % 12 === 0 ? 12 : hourOfDay % 12; + const minuteLabel = calendarEventStartDate + .getUTCMinutes() + .toString() + .padStart(2, '0'); + const meridiemLabel = hourOfDay < 12 ? 'AM' : 'PM'; + + return `${monthLabel} ${dayOfMonth}, ${year}, ${hourWithinHalfDay}:${minuteLabel} ${meridiemLabel} UTC`; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts index 60b4e5f99a..fb6e7f65e5 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts @@ -269,6 +269,32 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }); }); + it('creates a scheduled call recording with a fallback title when the calendar title is unavailable', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent({ title: undefined })], + }); + + const result = await reconcileCallRecorderForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + title: 'Call recording - Jan 1, 2026, 1:00 PM UTC', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + }), + ]); + }); + it('creates a scheduled call recording for the default ON preference', async () => { const client = buildFakeCoreApiClient({ calendarEvents: [buildCalendarEvent({ callRecorderPreference: null })], @@ -417,6 +443,44 @@ describe('reconcileCallRecorderForCalendarEventIds', () => { }); }); + it('replaces a stale visible title with the fallback title when the calendar title becomes unavailable', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent({ title: undefined })], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Old Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileCallRecorderForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + title: 'Call recording - Jan 1, 2026, 1:00 PM UTC', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + }), + ]); + }); + it('cancels an existing scheduled request when the policy no longer requests a bot', async () => { const client = buildFakeCoreApiClient({ calendarEvents: [ diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts new file mode 100644 index 0000000000..baabaad78e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/__tests__/reconcile-upcoming-calendar-event-batches.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { reconcileUpcomingCalendarEventBatches } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util'; + +const reconcileCallRecorderForCalendarEventIdsMock = vi.hoisted(() => vi.fn()); +const requestUpcomingCalendarEventsReconciliationMock = vi.hoisted(() => + vi.fn(), +); + +vi.mock('src/logic-functions/flows/reconcile-call-recorder.util', () => ({ + reconcileCallRecorderForCalendarEventIds: + reconcileCallRecorderForCalendarEventIdsMock, +})); + +vi.mock( + 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util', + () => ({ + requestUpcomingCalendarEventsReconciliation: + requestUpcomingCalendarEventsReconciliationMock, + }), +); + +const CLIENT: CoreApiClient = Object.assign( + Object.create(CoreApiClient.prototype), + { + mutation: vi.fn(), + query: vi.fn(), + }, +); + +const buildCalendarEventIds = (count: number): string[] => + Array.from({ length: count }, (_, index) => `calendar-event-${index + 1}`); + +describe('reconcileUpcomingCalendarEventBatches', () => { + beforeEach(() => { + vi.clearAllMocks(); + reconcileCallRecorderForCalendarEventIdsMock.mockResolvedValue([ + { + action: 'CREATED', + realMeetingKey: 'link:meet.example.com/abc:2026-07-05T10:00:00.000Z', + callRecordingId: 'call-recording-1', + }, + ]); + requestUpcomingCalendarEventsReconciliationMock.mockResolvedValue(true); + }); + + it('reconciles every batch when the deadline is far away', async () => { + const calendarEventIds = buildCalendarEventIds(30); + + const result = await reconcileUpcomingCalendarEventBatches({ + client: CLIENT, + calendarEventIds, + deadlineAtMs: Date.now() + 60_000, + }); + + expect(reconcileCallRecorderForCalendarEventIdsMock).toHaveBeenCalledTimes( + 2, + ); + expect( + reconcileCallRecorderForCalendarEventIdsMock, + ).toHaveBeenNthCalledWith(1, { + client: CLIENT, + calendarEventIds: calendarEventIds.slice(0, 25), + }); + expect( + reconcileCallRecorderForCalendarEventIdsMock, + ).toHaveBeenNthCalledWith(2, { + client: CLIENT, + calendarEventIds: calendarEventIds.slice(25), + }); + expect(result.reconciledCalendarEventIds).toEqual(calendarEventIds); + expect(result.remainingCalendarEventIds).toEqual([]); + expect(result.actionCounts).toEqual({ + created: 2, + updated: 0, + canceled: 0, + skipped: 0, + failed: 0, + }); + expect(result.continuationRequested).toBe(false); + expect( + requestUpcomingCalendarEventsReconciliationMock, + ).not.toHaveBeenCalled(); + }); + + it('stops at the deadline and requests a continuation with the remaining ids', async () => { + const calendarEventIds = buildCalendarEventIds(30); + // Clock advances 5s per reading: after one batch, 15s + 5s overshoots the deadline. + let nowMs = 0; + const getNowMs = () => { + nowMs += 5_000; + + return nowMs; + }; + + const result = await reconcileUpcomingCalendarEventBatches({ + client: CLIENT, + calendarEventIds, + deadlineAtMs: 15_000, + getNowMs, + }); + + expect(reconcileCallRecorderForCalendarEventIdsMock).toHaveBeenCalledTimes( + 1, + ); + expect(result.reconciledCalendarEventIds).toEqual( + calendarEventIds.slice(0, 25), + ); + expect(result.remainingCalendarEventIds).toEqual( + calendarEventIds.slice(25), + ); + expect(result.continuationRequested).toBe(true); + expect( + requestUpcomingCalendarEventsReconciliationMock, + ).toHaveBeenCalledWith({ + calendarEventIds: calendarEventIds.slice(25), + }); + }); + + it('records a failed batch and keeps processing the next one', async () => { + const calendarEventIds = buildCalendarEventIds(30); + + reconcileCallRecorderForCalendarEventIdsMock + .mockRejectedValueOnce(new Error('core api unavailable')) + .mockResolvedValueOnce([ + { + action: 'UPDATED', + realMeetingKey: 'link:meet.example.com/xyz:2026-07-06T10:00:00.000Z', + callRecordingId: 'call-recording-2', + }, + ]); + + const result = await reconcileUpcomingCalendarEventBatches({ + client: CLIENT, + calendarEventIds, + deadlineAtMs: Date.now() + 60_000, + }); + + expect(result.failedCalendarEventIds).toEqual(calendarEventIds.slice(0, 25)); + expect(result.reconciledCalendarEventIds).toEqual( + calendarEventIds.slice(25), + ); + expect(result.remainingCalendarEventIds).toEqual([]); + expect(result.actionCounts).toEqual({ + created: 0, + updated: 1, + canceled: 0, + skipped: 0, + failed: 0, + }); + expect(result.continuationRequested).toBe(false); + }); + + it('tallies every reconciliation action kind', async () => { + reconcileCallRecorderForCalendarEventIdsMock.mockResolvedValue([ + { + action: 'CREATED', + realMeetingKey: 'meeting-1', + callRecordingId: 'call-recording-1', + }, + { + action: 'CANCELED', + realMeetingKey: 'meeting-2', + callRecordingId: 'call-recording-2', + }, + { action: 'SKIPPED', realMeetingKey: 'meeting-3', callRecordingId: null }, + { + action: 'FAILED', + realMeetingKey: 'meeting-4', + errorMessage: 'recall rejected the bot', + }, + ]); + + const result = await reconcileUpcomingCalendarEventBatches({ + client: CLIENT, + calendarEventIds: buildCalendarEventIds(4), + deadlineAtMs: Date.now() + 60_000, + }); + + expect(result.actionCounts).toEqual({ + created: 1, + updated: 0, + canceled: 1, + skipped: 1, + failed: 1, + }); + }); +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts index 89c91e9817..9767a6f3a8 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-call-recorder.util.ts @@ -23,6 +23,7 @@ import { findCallRecordingsByCalendarEventIds } from 'src/logic-functions/data/f import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util'; import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; import { rescheduleCallRecordingBot } from 'src/logic-functions/flows/reschedule-call-recording-bot.util'; +import { resolveCallRecordingTitle } from 'src/logic-functions/domain/resolve-call-recording-title.util'; import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type'; @@ -344,15 +345,20 @@ const createPolicyManagedCallRecording = async ({ } // Winning the deterministic-id insert elects this run as the single writer that creates the bot. - await ensureCallRecorder(client, { + const didScheduleBot = await ensureCallRecorder(client, { callRecording: { id: callRecordingId, ...scheduledFields, - title: scheduledFields.title ?? undefined, }, calendarEvent: representativeCalendarEvent, }); + if (!didScheduleBot && process.env.NODE_ENV !== 'test') { + console.warn( + `[call-recorder] created callRecording ${callRecordingId}, but did not schedule a Recall bot`, + ); + } + return { action: 'CREATED', realMeetingKey, @@ -434,8 +440,7 @@ const reconcileCanceledMeeting = async ({ const buildCalendarDrivenCallRecordingFields = ( calendarEvent: CalendarEventRecord, ): Omit => ({ - // Wire null clears a stale title when the calendar title is gone or restricted. - title: calendarEvent.title ?? null, + title: resolveCallRecordingTitle(calendarEvent), recordingRequestStatus: CallRecordingRequestStatus.REQUESTED, calendarEventId: calendarEvent.id, }); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type.ts new file mode 100644 index 0000000000..ff3e60890d --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type.ts @@ -0,0 +1,14 @@ +import { type CallRecorderReconciliationResult } from 'src/logic-functions/types/call-recorder-reconciliation-result.type'; + +export type CallRecorderReconciliationActionCounts = Record< + Lowercase, + number +>; + +export type ReconcileUpcomingCalendarEventBatchesResult = { + reconciledCalendarEventIds: string[]; + failedCalendarEventIds: string[]; + remainingCalendarEventIds: string[]; + actionCounts: CallRecorderReconciliationActionCounts; + continuationRequested: boolean; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util.ts new file mode 100644 index 0000000000..65626dbe42 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util.ts @@ -0,0 +1,100 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { UPCOMING_CALENDAR_EVENT_RECONCILIATION_BATCH_SIZE } from 'src/logic-functions/constants/upcoming-calendar-event-reconciliation-batch-size'; +import { requestUpcomingCalendarEventsReconciliation } from 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util'; +import { reconcileCallRecorderForCalendarEventIds } from 'src/logic-functions/flows/reconcile-call-recorder.util'; +import { + type CallRecorderReconciliationActionCounts, + type ReconcileUpcomingCalendarEventBatchesResult, +} from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type'; +import { type CallRecorderReconciliationResult } from 'src/logic-functions/types/call-recorder-reconciliation-result.type'; + +const ACTION_COUNT_KEY_BY_ACTION: { + [Action in CallRecorderReconciliationResult['action']]: Lowercase; +} = { + CREATED: 'created', + UPDATED: 'updated', + CANCELED: 'canceled', + SKIPPED: 'skipped', + FAILED: 'failed', +}; + +export const reconcileUpcomingCalendarEventBatches = async ({ + client, + calendarEventIds, + deadlineAtMs, + getNowMs = () => Date.now(), +}: { + client: CoreApiClient; + calendarEventIds: string[]; + deadlineAtMs: number; + getNowMs?: () => number; +}): Promise => { + const remainingCalendarEventIds = [...calendarEventIds]; + const reconciledCalendarEventIds: string[] = []; + const failedCalendarEventIds: string[] = []; + const actionCounts: CallRecorderReconciliationActionCounts = { + created: 0, + updated: 0, + canceled: 0, + skipped: 0, + failed: 0, + }; + let slowestBatchMs = 0; + + // Process at least one batch per run so the continuation payload strictly shrinks. + while (remainingCalendarEventIds.length > 0) { + const batchCalendarEventIds = remainingCalendarEventIds.slice( + 0, + UPCOMING_CALENDAR_EVENT_RECONCILIATION_BATCH_SIZE, + ); + const batchStartedAtMs = getNowMs(); + + try { + const reconciliationResults = + await reconcileCallRecorderForCalendarEventIds({ + client, + calendarEventIds: batchCalendarEventIds, + }); + + reconciledCalendarEventIds.push(...batchCalendarEventIds); + + for (const reconciliationResult of reconciliationResults) { + const actionCountKey = + ACTION_COUNT_KEY_BY_ACTION[reconciliationResult.action]; + + actionCounts[actionCountKey] += 1; + } + } catch (error) { + failedCalendarEventIds.push(...batchCalendarEventIds); + + if (process.env.NODE_ENV !== 'test') { + console.error( + `[call-recorder] upcoming calendar event batch reconciliation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + remainingCalendarEventIds.splice(0, batchCalendarEventIds.length); + slowestBatchMs = Math.max(slowestBatchMs, getNowMs() - batchStartedAtMs); + + if (getNowMs() + slowestBatchMs > deadlineAtMs) { + break; + } + } + + const continuationRequested = + remainingCalendarEventIds.length > 0 + ? await requestUpcomingCalendarEventsReconciliation({ + calendarEventIds: remainingCalendarEventIds, + }) + : false; + + return { + reconciledCalendarEventIds, + failedCalendarEventIds, + remainingCalendarEventIds, + actionCounts, + continuationRequested, + }; +}; diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-upcoming-calendar-events.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-upcoming-calendar-events.ts new file mode 100644 index 0000000000..4b976fdb5e --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/reconcile-upcoming-calendar-events.ts @@ -0,0 +1,69 @@ +import { isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; + +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/reconcile-upcoming-calendar-events-logic-function-universal-identifier'; +import { RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH } from 'src/constants/reconcile-upcoming-calendar-events-route-path'; +import { fetchUpcomingCalendarEventIds } from 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util'; +import { reconcileUpcomingCalendarEventBatches } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util'; +import { type ReconcileUpcomingCalendarEventBatchesResult } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const TIMEOUT_SECONDS = 900; +const CONTINUATION_RESERVE_MS = 30_000; + +type ReconcileUpcomingCalendarEventsRouteBody = { + calendarEventIds?: unknown; +}; + +type ReconcileUpcomingCalendarEventsRouteResult = + | { outcome: 'nothing-selected' } + | { outcome: 'nothing-to-reconcile' } + | ({ outcome: 'processed' } & ReconcileUpcomingCalendarEventBatchesResult); + +const toIdList = (value: unknown): string[] => + Array.isArray(value) ? value.filter(isNonEmptyString) : []; + +export const reconcileUpcomingCalendarEventsHandler = async ( + payload: RoutePayload, +): Promise => { + const startedAtMs = Date.now(); + const client = new CoreApiClient(); + + const requestedCalendarEventIds = payload.body?.calendarEventIds; + const isSweep = isUndefined(requestedCalendarEventIds); + + const calendarEventIds = isSweep + ? await fetchUpcomingCalendarEventIds(client, new Date(startedAtMs)) + : toIdList(requestedCalendarEventIds); + + if (calendarEventIds.length === 0) { + return isSweep + ? { outcome: 'nothing-to-reconcile' } + : { outcome: 'nothing-selected' }; + } + + const result = await reconcileUpcomingCalendarEventBatches({ + client, + calendarEventIds, + deadlineAtMs: + startedAtMs + TIMEOUT_SECONDS * 1000 - CONTINUATION_RESERVE_MS, + }); + + return { outcome: 'processed', ...result }; +}; + +export default defineLogicFunction({ + universalIdentifier: + RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'reconcile-upcoming-calendar-events', + description: + 'Sweeps upcoming calendar events through reconciliation, self-continuing with the remaining ids near the timeout.', + timeoutSeconds: TIMEOUT_SECONDS, + handler: reconcileUpcomingCalendarEventsHandler, + httpRouteTriggerSettings: { + path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH, + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts deleted file mode 100644 index eee358a595..0000000000 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/start-call-recording-summary-backfill-on-install.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - definePostInstallLogicFunction, - type InstallPayload, -} from 'twenty-sdk/define'; - -import { START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/start-call-recording-summary-backfill-on-install-logic-function-universal-identifier'; -import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util'; - -export const startCallRecordingSummaryBackfillOnInstallHandler = async ({ - previousVersion, -}: InstallPayload): Promise => { - if (previousVersion === undefined) { - return { outcome: 'skipped-initial-install' }; - } - - const backfillRequested = await requestCallRecordingSummariesBackfill(); - - return { - outcome: backfillRequested - ? 'backfill-requested' - : 'backfill-request-failed', - }; -}; - -export default definePostInstallLogicFunction({ - universalIdentifier: - START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, - name: 'start-call-recording-summary-backfill-on-install', - description: - 'Starts the missing summary backfill worker when Call Recorder is upgraded in a workspace.', - timeoutSeconds: 30, - shouldRunOnVersionUpgrade: true, - handler: startCallRecordingSummaryBackfillOnInstallHandler, -}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/start-post-install-backfills.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/start-post-install-backfills.ts new file mode 100644 index 0000000000..e171dd22f8 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/start-post-install-backfills.ts @@ -0,0 +1,55 @@ +import { isUndefined } from '@sniptt/guards'; +import { + definePostInstallLogicFunction, + type InstallPayload, +} from 'twenty-sdk/define'; + +import { START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/start-post-install-backfills-logic-function-universal-identifier'; +import { requestCallRecordingSummariesBackfill } from 'src/logic-functions/data/request-call-recording-summaries-backfill.util'; +import { requestUpcomingCalendarEventsReconciliation } from 'src/logic-functions/data/request-upcoming-calendar-events-reconciliation.util'; + +// An app is allowed a single post-install hook, so the two backfills share it: +// a fresh install seeds the scheduling window, an upgrade relies on the scheduled sweep and backfills summaries. +type StartPostInstallBackfillsResult = { + calendarEventSweepOutcome: 'sweep-requested' | 'skipped-upgrade'; + summaryBackfillOutcome: 'skipped-initial-install' | 'backfill-requested'; +}; + +export const startPostInstallBackfillsHandler = async ({ + previousVersion, +}: InstallPayload): Promise => { + if (isUndefined(previousVersion)) { + if (!(await requestUpcomingCalendarEventsReconciliation())) { + throw new Error( + '[call-recorder] Failed to start post-install backfills: upcoming calendar event sweep', + ); + } + + return { + calendarEventSweepOutcome: 'sweep-requested', + summaryBackfillOutcome: 'skipped-initial-install', + }; + } + + if (!(await requestCallRecordingSummariesBackfill())) { + throw new Error( + '[call-recorder] Failed to start post-install backfills: call recording summary backfill', + ); + } + + return { + calendarEventSweepOutcome: 'skipped-upgrade', + summaryBackfillOutcome: 'backfill-requested', + }; +}; + +export default definePostInstallLogicFunction({ + universalIdentifier: + START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'start-post-install-backfills', + description: + 'Schedules recording bots for upcoming meetings on install, and backfills missing call recording summaries on upgrade.', + timeoutSeconds: 30, + shouldRunOnVersionUpgrade: true, + handler: startPostInstallBackfillsHandler, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/sweep-upcoming-calendar-events.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/sweep-upcoming-calendar-events.ts new file mode 100644 index 0000000000..36da59f112 --- /dev/null +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/sweep-upcoming-calendar-events.ts @@ -0,0 +1,52 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/sweep-upcoming-calendar-events-logic-function-universal-identifier'; +import { UPCOMING_CALENDAR_EVENTS_SWEEP_CRON_PATTERN } from 'src/logic-functions/constants/upcoming-calendar-events-sweep-cron-pattern'; +import { fetchUpcomingCalendarEventIds } from 'src/logic-functions/data/fetch-upcoming-calendar-event-ids.util'; +import { reconcileUpcomingCalendarEventBatches } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches.util'; +import { type ReconcileUpcomingCalendarEventBatchesResult } from 'src/logic-functions/flows/reconcile-upcoming-calendar-event-batches-result.type'; + +const TIMEOUT_SECONDS = 900; +const CONTINUATION_RESERVE_MS = 30_000; + +type SweepUpcomingCalendarEventsResult = + | { outcome: 'nothing-to-reconcile' } + | ({ outcome: 'processed' } & ReconcileUpcomingCalendarEventBatchesResult); + +export const sweepUpcomingCalendarEventsHandler = + async (): Promise => { + const startedAtMs = Date.now(); + const client = new CoreApiClient(); + + const calendarEventIds = await fetchUpcomingCalendarEventIds( + client, + new Date(startedAtMs), + ); + + if (calendarEventIds.length === 0) { + return { outcome: 'nothing-to-reconcile' }; + } + + const result = await reconcileUpcomingCalendarEventBatches({ + client, + calendarEventIds, + deadlineAtMs: + startedAtMs + TIMEOUT_SECONDS * 1000 - CONTINUATION_RESERVE_MS, + }); + + return { outcome: 'processed', ...result }; + }; + +export default defineLogicFunction({ + universalIdentifier: + SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'sweep-upcoming-calendar-events', + description: + 'Reconciles upcoming calendar events on a schedule so meetings entering the scheduling horizon get their recording bots.', + timeoutSeconds: TIMEOUT_SECONDS, + handler: sweepUpcomingCalendarEventsHandler, + cronTriggerSettings: { + pattern: UPCOMING_CALENDAR_EVENTS_SWEEP_CRON_PATTERN, + }, +}); diff --git a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts index ec296d1784..2423e57ea4 100644 --- a/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts +++ b/packages/twenty-apps/public/call-recorder/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts @@ -2,4 +2,5 @@ export type CallRecorderPolicyNotRequiredReason = | 'EVENT_CANCELED' | 'PREFERENCE_OFF' | 'MISSING_CONFERENCE_LINK' - | 'EVENT_NOT_UPCOMING'; + | 'EVENT_NOT_UPCOMING' + | 'EVENT_BEYOND_SCHEDULING_HORIZON';