Call recorder: sweep upcoming calendar events for recording bots on install (#22552)
## Context The Call Recorder schedules Recall bots reactively — a database event trigger reconciles a calendar event when it is created or updated. That misses meetings that already existed before the app was installed, and meetings created far ahead that are never edited as they approach. Neither gets a bot, though recording is on by default. ## What this PR does Moves to the rolling near-term window Recall recommends for [your own calendar integration](https://docs.recall.ai/docs/creating-and-scheduling-bots#scheduling-bots-with-your-own-calendar-integration) — "a daily sync of the next 7 days". Bots are scheduled only for meetings starting within a **7-day horizon**, kept complete by three mechanisms: - **Horizon (policy).** `resolveCallRecorderPolicyResult` caps scheduling at 7 days from now (`EVENT_BEYOND_SCHEDULING_HORIZON`), measured from `startsAt` (the bot's join time). The existing reactive trigger inherits this — far-future creates no longer schedule, and a meeting moved out of the window has its bot canceled. - **Daily sweep (cron).** New `sweep-upcoming-calendar-events` reconciles the 7-day window each day, so a meeting that ages into it without being edited still gets a bot. - **Fresh-install seed (post-install).** The app's single post-install hook (`start-post-install-backfills`) runs the sweep once on a fresh install so a new workspace is covered right away instead of waiting for the first cron; on an upgrade it relies on the cron and backfills missing summaries instead. The sweep runs through the authenticated `reconcile-upcoming-calendar-events` route, which batches ids through the existing reconciliation flow and re-invokes itself near the 900s timeout. Deterministic recording ids keep it idempotent. App self-calls go through a shared `postToOwnRoute` util targeting the server-injected `TWENTY_FUNCTIONS_URL`; a failed kickoff throws so the async hook retries instead of going silently green. Also: fallback titles for call recordings whose calendar event is visibility-restricted; app version → 1.0.7. ## Deferred - Far-future bots already scheduled by the previous no-cap behavior aren't proactively canceled — they fire naturally, or cancel if their event is edited out of the window. - Recall rejects an in-place `join_at` update under 10 min out; today that logs a warning rather than delete-and-recreate. ## Test plan - `yarn test:unit`: 407 tests / 65 files pass — new coverage for the horizon (including a meeting that starts in-window but ends beyond it), the 7-day query filter, the cron handler, the post-install hook's fresh-install vs upgrade branches, and the batch/continuation flow. - `yarn typecheck`, `yarn lint`, and `yarn twenty dev:build` (manifest build) pass. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22552?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
export const RECONCILE_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'06fafff7-c722-41d1-869d-9554736f4c53';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH =
|
||||
'/call-recorder/reconcile-upcoming-calendar-events';
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
export const START_CALL_RECORDING_SUMMARY_BACKFILL_ON_INSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const START_POST_INSTALL_BACKFILLS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'53e0acb4-b761-40c9-8aaf-554d2a5da00f';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const SWEEP_UPCOMING_CALENDAR_EVENTS_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'05ab984e-c6ad-4b1c-800f-8f029dc7aac8';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const TWENTY_FUNCTIONS_URL_ENV_VAR_NAME = 'TWENTY_FUNCTIONS_URL';
|
||||
+26
-6
@@ -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: [] });
|
||||
|
||||
|
||||
+11
-2
@@ -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<GenerateSummariesResponse>(
|
||||
`/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
|
||||
isNonEmptyString(functionsBaseUrl)
|
||||
? GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH
|
||||
: `/s${GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH}`,
|
||||
{ calendarEventIds },
|
||||
);
|
||||
|
||||
|
||||
+137
@@ -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);
|
||||
});
|
||||
});
|
||||
-62
@@ -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' });
|
||||
});
|
||||
});
|
||||
+102
@@ -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();
|
||||
});
|
||||
});
|
||||
+92
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const UPCOMING_CALENDAR_EVENT_RECONCILIATION_BATCH_SIZE = 25;
|
||||
+4
@@ -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;
|
||||
+2
@@ -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 * * *';
|
||||
+101
@@ -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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
+75
@@ -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();
|
||||
});
|
||||
});
|
||||
+10
-23
@@ -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);
|
||||
});
|
||||
|
||||
+46
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+75
@@ -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<string[]> => {
|
||||
const nowIsoString = now.toISOString();
|
||||
const horizonEndIsoString =
|
||||
computeUpcomingCalendarEventHorizonEnd(now).toISOString();
|
||||
|
||||
const calendarEventNodes = await fetchAllNodes<CalendarEventIdNode>(
|
||||
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<CalendarEventIdNode>
|
||||
| undefined;
|
||||
},
|
||||
);
|
||||
|
||||
return getUniqueSortedIds(
|
||||
calendarEventNodes.map((calendarEvent) => calendarEvent.id),
|
||||
);
|
||||
};
|
||||
+40
@@ -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<boolean> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
+6
-33
@@ -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<boolean> => {
|
||||
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<boolean> =>
|
||||
postToOwnRoute({
|
||||
path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
|
||||
body: {},
|
||||
});
|
||||
|
||||
+6
-32
@@ -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<boolean> => {
|
||||
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<boolean> =>
|
||||
postToOwnRoute({
|
||||
path: GENERATE_CALL_RECORDING_SUMMARIES_ROUTE_PATH,
|
||||
body: { callRecordingIds },
|
||||
});
|
||||
|
||||
+14
@@ -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<boolean> =>
|
||||
postToOwnRoute({
|
||||
path: RECONCILE_UPCOMING_CALENDAR_EVENTS_ROUTE_PATH,
|
||||
body: isUndefined(calendarEventIds) ? {} : { calendarEventIds },
|
||||
});
|
||||
+14
@@ -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;
|
||||
};
|
||||
+57
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+32
@@ -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');
|
||||
});
|
||||
});
|
||||
+11
@@ -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;
|
||||
};
|
||||
+47
-10
@@ -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 = (
|
||||
|
||||
+61
@@ -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<CalendarEventRecord, 'startsAt' | 'title'>,
|
||||
): 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`;
|
||||
};
|
||||
+64
@@ -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: [
|
||||
|
||||
+189
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-4
@@ -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<ScheduledCallRecordingFields, 'status'> => ({
|
||||
// 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,
|
||||
});
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type CallRecorderReconciliationResult } from 'src/logic-functions/types/call-recorder-reconciliation-result.type';
|
||||
|
||||
export type CallRecorderReconciliationActionCounts = Record<
|
||||
Lowercase<CallRecorderReconciliationResult['action']>,
|
||||
number
|
||||
>;
|
||||
|
||||
export type ReconcileUpcomingCalendarEventBatchesResult = {
|
||||
reconciledCalendarEventIds: string[];
|
||||
failedCalendarEventIds: string[];
|
||||
remainingCalendarEventIds: string[];
|
||||
actionCounts: CallRecorderReconciliationActionCounts;
|
||||
continuationRequested: boolean;
|
||||
};
|
||||
+100
@@ -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<Action>;
|
||||
} = {
|
||||
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<ReconcileUpcomingCalendarEventBatchesResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
+69
@@ -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<ReconcileUpcomingCalendarEventsRouteBody>,
|
||||
): Promise<ReconcileUpcomingCalendarEventsRouteResult> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
-34
@@ -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<object> => {
|
||||
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,
|
||||
});
|
||||
+55
@@ -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<StartPostInstallBackfillsResult> => {
|
||||
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,
|
||||
});
|
||||
+52
@@ -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<SweepUpcomingCalendarEventsResult> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
+2
-1
@@ -2,4 +2,5 @@ export type CallRecorderPolicyNotRequiredReason =
|
||||
| 'EVENT_CANCELED'
|
||||
| 'PREFERENCE_OFF'
|
||||
| 'MISSING_CONFERENCE_LINK'
|
||||
| 'EVENT_NOT_UPCOMING';
|
||||
| 'EVENT_NOT_UPCOMING'
|
||||
| 'EVENT_BEYOND_SCHEDULING_HORIZON';
|
||||
|
||||
Reference in New Issue
Block a user