Compare commits

...
42 Commits
Author SHA1 Message Date
ehconitin 9e6d384e9b add upgrade command 2026-06-05 21:28:38 +05:30
ehconitin 62dce10699 stash 2026-06-05 19:48:06 +05:30
ehconitin a81dd55f9d Merge remote-tracking branch 'upstream/main' into call-recording-api 2026-06-05 18:40:32 +05:30
ehconitin 22f9c3517b Merge remote-tracking branch 'upstream/call-recording-standard-object' into ehco/intelligent-benz-a7fa3b 2026-06-05 18:25:28 +05:30
ehconitin c629ea858b feat(server): add calendar event recording decision layer 2026-06-05 18:25:11 +05:30
ehconitin 05ea7ac120 Scope each call recording collision rename to its entity's application 2026-06-05 18:00:46 +05:30
ehconitin 0c1ca75981 Revert "Use from-to migration for call recording sync"
This reverts commit 312b6969aa.
2026-06-05 17:27:17 +05:30
ehconitin 1f63ff0f3d Revert "fix"
This reverts commit cc93aaa6b4.
2026-06-05 17:27:17 +05:30
ehconitin cc93aaa6b4 fix 2026-06-05 16:56:37 +05:30
ehconitin 312b6969aa Use from-to migration for call recording sync 2026-06-05 16:36:08 +05:30
ehconitin dd46d74d41 throw dont skip 2026-06-05 14:44:52 +05:30
ehconitin 1118c40864 fix 2026-06-05 14:28:40 +05:30
ehconitin 817cf18075 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-05 13:42:15 +05:30
ehconitin 544ebd487a Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-04 23:04:13 +05:30
ehconitin 5672a090f8 fix(call-recording): resequence upgrade command timestamp 2026-06-04 19:30:34 +05:30
ehconitin 8a8310dee3 Fix CallRecording standard object upgrade collisions 2026-06-04 19:12:28 +05:30
ehconitin 97c6b3a4b8 fix(call-recording): move upgrade command to 2-10 after main bumped to 2.10.0 2026-06-04 18:59:22 +05:30
ehconitin 8ce69d52ab refactor(call-recording): operation-based 2.9 upgrade sync with tested utils 2026-06-04 18:30:29 +05:30
ehconitin 9f76d44c22 fix(call-recording): use IconVideo for the call recording standard object 2026-06-04 18:30:29 +05:30
ehconitin e36d6290ea feat(call-recording): use snapshotted title as label identifier 2026-06-04 17:01:50 +05:30
ehconitin 33f3b4fbf9 fix(call-recording): inline immutable replace after upstream removed flat-entity util 2026-06-04 16:43:57 +05:30
ehconitin d8d02cb388 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-04 16:24:20 +05:30
ehconitin 73e619174f fix(call-recording): rename all colliding custom objects, not just the first 2026-06-04 15:10:37 +05:30
ehconitin 426f87dfe6 update field name sourceApplicationId to applicationId 2026-06-04 14:37:39 +05:30
ehconitin 807ef86907 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object
# Conflicts:
#	packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts
2026-06-04 14:29:20 +05:30
ehconitin 7e7ff345da fix(call-recording): simplify failure state and calendar retention 2026-06-04 13:41:05 +05:30
ehconitin 69219d8871 fix(call-recording): resolve upgrade command timestamp collision with upstream 2026-06-04 12:22:04 +05:30
ehconitin 7a60706437 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object
# Conflicts:
#	packages/twenty-client-sdk/src/metadata/generated/schema.graphql
#	packages/twenty-client-sdk/src/metadata/generated/schema.ts
#	packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts
#	packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts
#	packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap
#	packages/twenty-shared/src/types/FeatureFlagKey.ts
2026-06-04 12:02:17 +05:30
ehconitin 62473fc5ea refactor(call-recording): collapse junction into direct CalendarEvent relation 2026-06-04 02:19:32 +05:30
ehconitin ff7819a353 fix 2026-06-03 20:48:07 +05:30
ehconitin 94badfd1c6 collision navigation command menu 2026-06-03 20:26:40 +05:30
ehconitin 9a8e3e8bc6 gate navigation commands with featrue flag 2026-06-03 19:41:26 +05:30
ehconitin 0a510a68de Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-03 18:36:53 +05:30
ehconitin bfe1046af4 feat(call-recording): sync full metadata graph in upgrade command, refine views, extract collision helpers 2026-06-03 18:36:02 +05:30
ehconitin be9b7a5222 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-03 15:04:28 +05:30
ehconitin f770acbdee restore call-recording-calendar-event-association index view 2026-06-03 01:26:41 +05:30
ehconitin c68ff85f49 strip out unecessary code 2026-06-03 00:47:37 +05:30
ehconitin 7cc89c0811 regen 2026-06-02 23:01:42 +05:30
ehconitin 81da1bc2ac conflicts 2026-06-02 22:52:04 +05:30
ehconitin c2d3edb0f6 Merge remote-tracking branch 'upstream/main' into call-recording-standard-object 2026-06-02 22:51:55 +05:30
ehconitin c821869f9e chore: regenerate graphql and sdk metadata client for IS_CALL_RECORDING_ENABLED 2026-06-02 22:28:17 +05:30
ehconitin c36c1c7675 feat: add CallRecording standard object + 2.9 workspace sync command with custom-object collision rename 2026-06-02 21:59:45 +05:30
32 changed files with 2394 additions and 1 deletions
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SyncCallRecordingCanceledStatusCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-workspace-command-1799000060000-sync-call-recording-canceled-status.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [SyncCallRecordingCanceledStatusCommand],
})
export class V2_11_UpgradeVersionCommandModule {}
@@ -0,0 +1,142 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier;
const CANCELED_CALL_RECORDING_STATUS = 'CANCELED';
@RegisteredWorkspaceCommand('2.11.0', 1799000060000)
@Command({
name: 'upgrade:2-11:sync-call-recording-canceled-status',
description:
'Add the CANCELED option to the CallRecording status field in existing workspaces',
})
export class SyncCallRecordingCanceledStatusCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const existingStatusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingStatusField)) {
this.logger.log(
`CallRecording status field does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
if (hasCanceledOption(existingStatusField)) {
this.logger.log(
`CallRecording status field already includes ${CANCELED_CALL_RECORDING_STATUS} for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const standardStatusField =
standardAllFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(standardStatusField?.options)) {
throw new Error(
`Standard CallRecording status field options not found for workspace ${workspaceId}`,
);
}
const statusFieldToUpdate: FlatFieldMetadata = {
...existingStatusField,
options: standardStatusField.options,
updatedAt: new Date().toISOString(),
};
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Adding ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [statusFieldToUpdate],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to add ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Added ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}`,
);
}
}
const hasCanceledOption = (fieldMetadata: FlatFieldMetadata): boolean =>
fieldMetadata.options?.some(
(option) => option.value === CANCELED_CALL_RECORDING_STATUS,
) === true;
@@ -13,6 +13,7 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-11/2-11-upgrade-version-command.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_8_UpgradeVersionCommandModule,
V2_9_UpgradeVersionCommandModule,
V2_10_UpgradeVersionCommandModule,
V2_11_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -170,11 +170,18 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
position: 4,
color: 'green',
},
{
id: '28c6019d-e543-4d5a-a0b0-745999450270',
value: 'CANCELED',
label: i18nLabel(msg`Canceled`),
position: 5,
color: 'gray',
},
{
id: '4800777e-54a8-4464-9c01-07d6eefd04da',
value: 'FAILED_UNKNOWN',
label: i18nLabel(msg`Failed`),
position: 5,
position: 6,
color: 'gray',
},
],
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CalendarEventRecordingEvaluateCommand } from 'src/modules/calendar/calendar-event-recording-manager/commands/calendar-event-recording-evaluate.command';
import { CalendarEventRecordingDecisionJob } from 'src/modules/calendar/calendar-event-recording-manager/jobs/calendar-event-recording-decision.job';
import { CalendarEventRecordingListener } from 'src/modules/calendar/calendar-event-recording-manager/listeners/calendar-event-recording.listener';
import { CalendarEventRecordingParticipantListener } from 'src/modules/calendar/calendar-event-recording-manager/listeners/calendar-event-recording-participant.listener';
import { CalendarEventRecordingDecisionService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-decision.service';
import { CalendarEventRecordingReconciliationService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-reconciliation.service';
@Module({
imports: [FeatureFlagModule],
providers: [
CalendarEventRecordingDecisionService,
CalendarEventRecordingReconciliationService,
CalendarEventRecordingDecisionJob,
CalendarEventRecordingListener,
CalendarEventRecordingParticipantListener,
CalendarEventRecordingEvaluateCommand,
],
exports: [
CalendarEventRecordingDecisionService,
CalendarEventRecordingReconciliationService,
],
})
export class CalendarEventRecordingManagerModule {}
@@ -0,0 +1,82 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { CalendarEventRecordingDecisionService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-decision.service';
type CalendarEventRecordingEvaluateCommandOptions = {
workspaceId: string;
calendarEventId: string;
};
@Command({
name: 'calendar-event-recording:evaluate',
description:
'Evaluate the recording decision for a single calendar event and print the result, without scheduling a bot',
})
export class CalendarEventRecordingEvaluateCommand extends CommandRunner {
private readonly logger = new Logger(
CalendarEventRecordingEvaluateCommand.name,
);
constructor(
private readonly calendarEventRecordingDecisionService: CalendarEventRecordingDecisionService,
) {
super();
}
async run(
_passedParams: string[],
options: CalendarEventRecordingEvaluateCommandOptions,
): Promise<void> {
const { workspaceId, calendarEventId } = options;
const decision =
await this.calendarEventRecordingDecisionService.evaluateCalendarEvent({
workspaceId,
calendarEventId,
});
if (!decision.found) {
this.logger.warn(
`Calendar event ${calendarEventId} not found in workspace ${workspaceId}`,
);
return;
}
const dispatchIntent =
decision.eventIntent === 'ACTIVE'
? 'would request bot'
: 'would cancel intent';
this.logger.log(
[
`Workspace: ${decision.workspaceId}`,
`Calendar event: ${decision.calendarEventId}`,
`Recording preference: ${decision.recordingPreference}`,
`Real meeting key: ${decision.realMeetingKey}`,
`Event intent: ${decision.eventIntent} (${decision.reason})`,
`Decision: ${dispatchIntent}`,
].join('\n'),
);
}
@Option({
flags: '-w, --workspace-id <workspace-id>',
description: 'Workspace ID',
required: true,
})
parseWorkspaceId(value: string): string {
return value;
}
@Option({
flags: '-c, --calendar-event-id <calendar-event-id>',
description: 'Calendar Event ID',
required: true,
})
parseCalendarEventId(value: string): string {
return value;
}
}
@@ -0,0 +1,50 @@
import { Logger, Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { CalendarEventRecordingDecisionService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-decision.service';
import { CalendarEventRecordingReconciliationService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-reconciliation.service';
export type CalendarEventRecordingDecisionJobData = {
workspaceId: string;
calendarEventIds: string[];
removedOccurrences?: RemovedRecordingOccurrence[];
};
@Processor({
queueName: MessageQueue.calendarQueue,
scope: Scope.REQUEST,
})
export class CalendarEventRecordingDecisionJob {
private readonly logger = new Logger(CalendarEventRecordingDecisionJob.name);
constructor(
private readonly calendarEventRecordingDecisionService: CalendarEventRecordingDecisionService,
private readonly calendarEventRecordingReconciliationService: CalendarEventRecordingReconciliationService,
) {}
@Process(CalendarEventRecordingDecisionJob.name)
async handle({
workspaceId,
calendarEventIds,
removedOccurrences,
}: CalendarEventRecordingDecisionJobData): Promise<void> {
const meetingAggregates =
await this.calendarEventRecordingDecisionService.evaluateMeetingOccurrences(
{ workspaceId, calendarEventIds, removedOccurrences },
);
const reconciliationResults =
await this.calendarEventRecordingReconciliationService.reconcileMeetingOccurrences(
{ workspaceId, meetingAggregates, removedOccurrences },
);
for (const reconciliationResult of reconciliationResults) {
this.logger.log(
`${reconciliationResult.action.toLowerCase()} call recording lifecycle in workspace ${workspaceId} with callRecordingId ${reconciliationResult.callRecordingId ?? 'none'}`,
);
}
}
}
@@ -0,0 +1,94 @@
import { CalendarEventRecordingDecisionJob } from 'src/modules/calendar/calendar-event-recording-manager/jobs/calendar-event-recording-decision.job';
import { CalendarEventRecordingParticipantListener } from 'src/modules/calendar/calendar-event-recording-manager/listeners/calendar-event-recording-participant.listener';
const mockMessageQueueService = {
add: jest.fn(),
};
describe('CalendarEventRecordingParticipantListener', () => {
let listener: CalendarEventRecordingParticipantListener;
beforeEach(() => {
jest.clearAllMocks();
listener = new CalendarEventRecordingParticipantListener(
mockMessageQueueService as any,
);
});
it('should re-evaluate the parent calendar event when a participant is created', async () => {
await listener.handleCreatedEvent({
workspaceId: 'workspace-1',
events: [{ properties: { after: { calendarEventId: 'event-1' } } }],
} as any);
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: ['event-1'],
removedOccurrences: [],
},
);
});
it('should re-evaluate only when a participant workspace member match changed', async () => {
await listener.handleUpdatedEvent({
workspaceId: 'workspace-1',
events: [
{
properties: {
updatedFields: ['workspaceMemberId'],
after: { calendarEventId: 'event-1' },
},
},
{
properties: {
updatedFields: ['responseStatus'],
after: { calendarEventId: 'event-2' },
},
},
],
} as any);
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: ['event-1'],
removedOccurrences: [],
},
);
});
it('should not enqueue when no participant changed the workspace member match', async () => {
await listener.handleUpdatedEvent({
workspaceId: 'workspace-1',
events: [
{
properties: {
updatedFields: ['responseStatus'],
after: { calendarEventId: 'event-1' },
},
},
],
} as any);
expect(mockMessageQueueService.add).not.toHaveBeenCalled();
});
it('should re-evaluate the parent calendar event when a participant is destroyed', async () => {
await listener.handleDestroyedEvent({
workspaceId: 'workspace-1',
events: [{ properties: { before: { calendarEventId: 'event-1' } } }],
} as any);
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: ['event-1'],
removedOccurrences: [],
},
);
});
});
@@ -0,0 +1,114 @@
import { CalendarEventRecordingDecisionJob } from 'src/modules/calendar/calendar-event-recording-manager/jobs/calendar-event-recording-decision.job';
import { CalendarEventRecordingListener } from 'src/modules/calendar/calendar-event-recording-manager/listeners/calendar-event-recording.listener';
const mockMessageQueueService = {
add: jest.fn(),
};
const OLD_CALENDAR_EVENT = {
id: 'event-1',
conferenceLink: {
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
iCalUid: 'ical-1',
startsAt: '2999-01-01T10:00:00.000Z',
};
const buildUpdatePayload = (updatedFields: string[]) =>
({
workspaceId: 'workspace-1',
events: [
{
recordId: 'event-1',
properties: { updatedFields, before: OLD_CALENDAR_EVENT },
},
],
}) as any;
describe('CalendarEventRecordingListener', () => {
let listener: CalendarEventRecordingListener;
beforeEach(() => {
jest.clearAllMocks();
listener = new CalendarEventRecordingListener(
mockMessageQueueService as any,
);
});
it('should enqueue a decision when a recording-relevant field changed', async () => {
await listener.handleUpdatedEvent(
buildUpdatePayload(['recordingPreference']),
);
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: ['event-1'],
removedOccurrences: [],
},
);
});
it('should enqueue the current event and previous occurrence when the meeting key changed', async () => {
await listener.handleUpdatedEvent(buildUpdatePayload(['conferenceLink']));
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: ['event-1'],
removedOccurrences: [
{
calendarEventId: 'event-1',
realMeetingKey:
'link:meet.google.com/abc-defg-hij:2999-01-01T10:00:00.000Z',
startsAt: '2999-01-01T10:00:00.000Z',
},
],
},
);
});
it('should not enqueue when only an irrelevant field changed', async () => {
await listener.handleUpdatedEvent(buildUpdatePayload(['title']));
expect(mockMessageQueueService.add).not.toHaveBeenCalled();
});
it('should enqueue a removed occurrence (key + start) when a calendar event is destroyed', async () => {
await listener.handleDestroyedEvent({
workspaceId: 'workspace-1',
events: [
{
properties: {
before: {
id: 'event-1',
conferenceLink: {
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij',
},
iCalUid: 'ical-1',
startsAt: '2999-01-01T10:00:00.000Z',
},
},
},
],
} as any);
expect(mockMessageQueueService.add).toHaveBeenCalledWith(
CalendarEventRecordingDecisionJob.name,
{
workspaceId: 'workspace-1',
calendarEventIds: [],
removedOccurrences: [
{
calendarEventId: 'event-1',
realMeetingKey:
'link:meet.google.com/abc-defg-hij:2999-01-01T10:00:00.000Z',
startsAt: '2999-01-01T10:00:00.000Z',
},
],
},
);
});
});
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import {
type ObjectRecordCreateEvent,
type ObjectRecordDeleteEvent,
type ObjectRecordDestroyEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-shared/database-events';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import {
CalendarEventRecordingDecisionJob,
type CalendarEventRecordingDecisionJobData,
} from 'src/modules/calendar/calendar-event-recording-manager/jobs/calendar-event-recording-decision.job';
import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
// The AUTO policy depends on whether a meeting has an external participant, and participants are
// matched to workspace members asynchronously after the event is created. So a participant change
// must re-evaluate its parent calendar event, otherwise the external-participant signal goes stale.
const WORKSPACE_MEMBER_ID_FIELD = 'workspaceMemberId';
@Injectable()
export class CalendarEventRecordingParticipantListener {
constructor(
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('calendarEventParticipant', DatabaseEventAction.CREATED)
async handleCreatedEvent(
payload: WorkspaceEventBatch<
ObjectRecordCreateEvent<CalendarEventParticipantWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueParentDecision(
payload.workspaceId,
payload.events.map((event) => event.properties.after.calendarEventId),
);
}
@OnDatabaseBatchEvent('calendarEventParticipant', DatabaseEventAction.UPDATED)
async handleUpdatedEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<CalendarEventParticipantWorkspaceEntity>
>,
): Promise<void> {
const calendarEventIds = payload.events
.filter((event) =>
(event.properties.updatedFields ?? []).includes(
WORKSPACE_MEMBER_ID_FIELD,
),
)
.map((event) => event.properties.after.calendarEventId);
await this.enqueueParentDecision(payload.workspaceId, calendarEventIds);
}
@OnDatabaseBatchEvent('calendarEventParticipant', DatabaseEventAction.DELETED)
async handleDeletedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<CalendarEventParticipantWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueParentDecision(
payload.workspaceId,
payload.events.map((event) => event.properties.before.calendarEventId),
);
}
@OnDatabaseBatchEvent(
'calendarEventParticipant',
DatabaseEventAction.DESTROYED,
)
async handleDestroyedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDestroyEvent<CalendarEventParticipantWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueParentDecision(
payload.workspaceId,
payload.events.map((event) => event.properties.before.calendarEventId),
);
}
private async enqueueParentDecision(
workspaceId: string,
calendarEventIds: (string | null | undefined)[],
): Promise<void> {
const definedCalendarEventIds = [
...new Set(calendarEventIds.filter(isDefined)),
];
if (definedCalendarEventIds.length === 0) {
return;
}
await this.messageQueueService.add<CalendarEventRecordingDecisionJobData>(
CalendarEventRecordingDecisionJob.name,
{
workspaceId,
calendarEventIds: definedCalendarEventIds,
removedOccurrences: [],
},
);
}
}
@@ -0,0 +1,161 @@
import { Injectable } from '@nestjs/common';
import {
type ObjectRecordCreateEvent,
type ObjectRecordDeleteEvent,
type ObjectRecordDestroyEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-shared/database-events';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import {
CalendarEventRecordingDecisionJob,
type CalendarEventRecordingDecisionJobData,
} from 'src/modules/calendar/calendar-event-recording-manager/jobs/calendar-event-recording-decision.job';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { computeRealMeetingKey } from 'src/modules/calendar/calendar-event-recording-manager/utils/compute-real-meeting-key.util';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
// Fields whose change can flip a recording decision; other calendar-event updates are ignored to
// avoid re-evaluating on noise. The composite conferenceLink surfaces under its parent name in
// updatedFields, so exact matching catches it.
const RECORDING_RELEVANT_CALENDAR_EVENT_FIELDS = [
'recordingPreference',
'conferenceLink',
'startsAt',
'endsAt',
'isCanceled',
'iCalUid',
];
const RECORDING_KEY_CALENDAR_EVENT_FIELDS = [
'conferenceLink',
'startsAt',
'iCalUid',
];
@Injectable()
export class CalendarEventRecordingListener {
constructor(
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('calendarEvent', DatabaseEventAction.CREATED)
async handleCreatedEvent(
payload: WorkspaceEventBatch<
ObjectRecordCreateEvent<CalendarEventWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueDecision({
workspaceId: payload.workspaceId,
calendarEventIds: payload.events.map((event) => event.recordId),
});
}
@OnDatabaseBatchEvent('calendarEvent', DatabaseEventAction.UPDATED)
async handleUpdatedEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<CalendarEventWorkspaceEntity>
>,
): Promise<void> {
const recordingRelevantEvents = payload.events.filter((event) =>
hasRecordingRelevantFieldChange(event.properties.updatedFields),
);
const calendarEventIds = recordingRelevantEvents.map(
(event) => event.recordId,
);
const removedOccurrences = recordingRelevantEvents
.filter((event) =>
hasRecordingKeyFieldChange(event.properties.updatedFields),
)
.map((event) => buildRemovedOccurrence(event.properties.before));
await this.enqueueDecision({
workspaceId: payload.workspaceId,
calendarEventIds,
removedOccurrences,
});
}
@OnDatabaseBatchEvent('calendarEvent', DatabaseEventAction.DELETED)
async handleDeletedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDeleteEvent<CalendarEventWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueDecision({
workspaceId: payload.workspaceId,
calendarEventIds: [],
removedOccurrences: payload.events.map((event) =>
buildRemovedOccurrence(event.properties.before),
),
});
}
@OnDatabaseBatchEvent('calendarEvent', DatabaseEventAction.DESTROYED)
async handleDestroyedEvent(
payload: WorkspaceEventBatch<
ObjectRecordDestroyEvent<CalendarEventWorkspaceEntity>
>,
): Promise<void> {
await this.enqueueDecision({
workspaceId: payload.workspaceId,
calendarEventIds: [],
removedOccurrences: payload.events.map((event) =>
buildRemovedOccurrence(event.properties.before),
),
});
}
private async enqueueDecision({
workspaceId,
calendarEventIds,
removedOccurrences = [],
}: {
workspaceId: string;
calendarEventIds: string[];
removedOccurrences?: RemovedRecordingOccurrence[];
}): Promise<void> {
if (calendarEventIds.length === 0 && removedOccurrences.length === 0) {
return;
}
await this.messageQueueService.add<CalendarEventRecordingDecisionJobData>(
CalendarEventRecordingDecisionJob.name,
{ workspaceId, calendarEventIds, removedOccurrences },
);
}
}
const hasRecordingRelevantFieldChange = (
updatedFields: string[] | undefined,
): boolean =>
(updatedFields ?? []).some((updatedField) =>
RECORDING_RELEVANT_CALENDAR_EVENT_FIELDS.includes(updatedField),
);
const hasRecordingKeyFieldChange = (
updatedFields: string[] | undefined,
): boolean =>
(updatedFields ?? []).some((updatedField) =>
RECORDING_KEY_CALENDAR_EVENT_FIELDS.includes(updatedField),
);
const buildRemovedOccurrence = (
calendarEvent: CalendarEventWorkspaceEntity,
): RemovedRecordingOccurrence => ({
calendarEventId: calendarEvent.id,
realMeetingKey: computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl ?? null,
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
}),
startsAt: calendarEvent.startsAt,
});
@@ -0,0 +1,219 @@
import { CalendarEventRecordingDecisionService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-decision.service';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
const buildCalendarEvent = (
overrides: Partial<CalendarEventWorkspaceEntity> = {},
): CalendarEventWorkspaceEntity =>
({
id: 'event-1',
recordingPreference: 'AUTO',
isCanceled: false,
// Far future so the "upcoming" check never depends on the wall clock.
startsAt: '2999-01-01T10:00:00.000Z',
endsAt: '2999-01-01T11:00:00.000Z',
iCalUid: 'ical-1',
conferenceLink: {
primaryLinkLabel: '',
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij',
secondaryLinks: null,
},
calendarEventParticipants: [{ workspaceMemberId: null }],
...overrides,
}) as unknown as CalendarEventWorkspaceEntity;
const mockCalendarEventRepository = {
findOne: jest.fn(),
find: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<unknown>) =>
callback(),
),
getRepository: jest.fn(async () => mockCalendarEventRepository),
};
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn(),
};
describe('CalendarEventRecordingDecisionService', () => {
let service: CalendarEventRecordingDecisionService;
const evaluate = () =>
service.evaluateCalendarEvent({
workspaceId: 'workspace-1',
calendarEventId: 'event-1',
});
beforeEach(() => {
jest.clearAllMocks();
mockFeatureFlagService.isFeatureEnabled.mockResolvedValue(true);
service = new CalendarEventRecordingDecisionService(
mockGlobalWorkspaceOrmManager as any,
mockFeatureFlagService as any,
);
});
it('should request recording for an AUTO event matching policy and expose the meeting key', async () => {
mockCalendarEventRepository.findOne.mockResolvedValue(buildCalendarEvent());
const result = await evaluate();
expect(result).toEqual({
workspaceId: 'workspace-1',
calendarEventId: 'event-1',
found: true,
recordingPreference: 'AUTO',
realMeetingKey:
'link:meet.google.com/abc-defg-hij:2999-01-01T10:00:00.000Z',
eventIntent: 'ACTIVE',
reason: 'AUTO_POLICY_MATCHED',
});
});
it('should request recording for an ON event even without a conference link or external participant', async () => {
mockCalendarEventRepository.findOne.mockResolvedValue(
buildCalendarEvent({
recordingPreference: 'ON',
conferenceLink:
null as unknown as CalendarEventWorkspaceEntity['conferenceLink'],
calendarEventParticipants: [],
}),
);
const result = await evaluate();
expect(result.eventIntent).toBe('ACTIVE');
expect(result.reason).toBe('PREFERENCE_ON');
});
it('should cancel intent for an OFF event', async () => {
mockCalendarEventRepository.findOne.mockResolvedValue(
buildCalendarEvent({ recordingPreference: 'OFF' }),
);
const result = await evaluate();
expect(result.eventIntent).toBe('CANCELED');
expect(result.reason).toBe('PREFERENCE_OFF');
});
it('should not request recording for an AUTO event with only internal participants', async () => {
mockCalendarEventRepository.findOne.mockResolvedValue(
buildCalendarEvent({
calendarEventParticipants: [
{ workspaceMemberId: 'workspace-member-1' },
] as unknown as CalendarEventWorkspaceEntity['calendarEventParticipants'],
}),
);
const result = await evaluate();
expect(result.eventIntent).toBe('CANCELED');
expect(result.reason).toBe('AUTO_NO_EXTERNAL_PARTICIPANT');
});
it('should cancel intent when call recording is disabled for the workspace', async () => {
mockFeatureFlagService.isFeatureEnabled.mockResolvedValue(false);
mockCalendarEventRepository.findOne.mockResolvedValue(
buildCalendarEvent({ recordingPreference: 'ON' }),
);
const result = await evaluate();
expect(result.eventIntent).toBe('CANCELED');
expect(result.reason).toBe('WORKSPACE_RECORDING_DISABLED');
});
it('should return a not-found result when the calendar event does not exist', async () => {
mockCalendarEventRepository.findOne.mockResolvedValue(null);
const result = await evaluate();
expect(result.found).toBe(false);
expect(result.eventIntent).toBeNull();
expect(result.realMeetingKey).toBeNull();
});
describe('evaluateMeetingOccurrences', () => {
const MEETING_KEY =
'link:meet.google.com/abc-defg-hij:2999-01-01T10:00:00.000Z';
it('should keep a meeting ACTIVE when a changed copy is OFF but another copy still wants it', async () => {
const offEvent = buildCalendarEvent({
id: 'event-off',
recordingPreference: 'OFF',
});
const onEvent = buildCalendarEvent({
id: 'event-on',
recordingPreference: 'ON',
});
mockCalendarEventRepository.find
.mockResolvedValueOnce([offEvent])
.mockResolvedValueOnce([offEvent, onEvent]);
const aggregates = await service.evaluateMeetingOccurrences({
workspaceId: 'workspace-1',
calendarEventIds: ['event-off'],
});
expect(aggregates).toHaveLength(1);
expect(aggregates[0].providerIntent).toBe('ACTIVE');
expect(aggregates[0].activeCalendarEventIds).toEqual(['event-on']);
});
it('should cancel a meeting whose only calendar event was removed', async () => {
mockCalendarEventRepository.find.mockResolvedValueOnce([]);
const aggregates = await service.evaluateMeetingOccurrences({
workspaceId: 'workspace-1',
calendarEventIds: [],
removedOccurrences: [
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
});
expect(aggregates).toEqual([
{
realMeetingKey: MEETING_KEY,
providerIntent: 'CANCELED',
calendarEventIds: [],
activeCalendarEventIds: [],
},
]);
});
it('should keep a meeting ACTIVE when one copy is removed but a surviving copy still wants it', async () => {
const survivingOnEvent = buildCalendarEvent({
id: 'event-on',
recordingPreference: 'ON',
});
mockCalendarEventRepository.find.mockResolvedValueOnce([
survivingOnEvent,
]);
const aggregates = await service.evaluateMeetingOccurrences({
workspaceId: 'workspace-1',
calendarEventIds: [],
removedOccurrences: [
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
});
expect(aggregates).toHaveLength(1);
expect(aggregates[0].providerIntent).toBe('ACTIVE');
expect(aggregates[0].activeCalendarEventIds).toEqual(['event-on']);
});
});
});
@@ -0,0 +1,230 @@
import { CalendarEventRecordingReconciliationService } from 'src/modules/calendar/calendar-event-recording-manager/services/calendar-event-recording-reconciliation.service';
import { type RealMeetingRecordingAggregate } from 'src/modules/calendar/calendar-event-recording-manager/types/real-meeting-recording-aggregate.type';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type CallRecordingWorkspaceEntity } from 'src/modules/call-recording/standard-objects/call-recording.workspace-entity';
const MEETING_KEY =
'link:meet.google.com/abc-defg-hij:2999-01-01T10:00:00.000Z';
const buildCalendarEvent = (
overrides: Partial<CalendarEventWorkspaceEntity> = {},
): CalendarEventWorkspaceEntity =>
({
id: 'event-1',
title: 'Customer sync',
startsAt: '2999-01-01T10:00:00.000Z',
endsAt: '2999-01-01T11:00:00.000Z',
...overrides,
}) as CalendarEventWorkspaceEntity;
const buildCallRecording = (
overrides: Partial<CallRecordingWorkspaceEntity> = {},
): CallRecordingWorkspaceEntity =>
({
id: 'call-recording-1',
status: 'SCHEDULED',
calendarEventId: 'event-1',
...overrides,
}) as CallRecordingWorkspaceEntity;
const buildActiveAggregate = (
overrides: Partial<RealMeetingRecordingAggregate> = {},
): RealMeetingRecordingAggregate => ({
realMeetingKey: MEETING_KEY,
providerIntent: 'ACTIVE',
calendarEventIds: ['event-1'],
activeCalendarEventIds: ['event-1'],
...overrides,
});
const buildCanceledAggregate = (
overrides: Partial<RealMeetingRecordingAggregate> = {},
): RealMeetingRecordingAggregate => ({
realMeetingKey: MEETING_KEY,
providerIntent: 'CANCELED',
calendarEventIds: ['event-1'],
activeCalendarEventIds: [],
...overrides,
});
const mockCalendarEventRepository = {
findOne: jest.fn(),
};
const mockCallRecordingRepository = {
find: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
executeInWorkspaceContext: jest.fn(async (callback: () => Promise<unknown>) =>
callback(),
),
getRepository: jest.fn(),
};
describe('CalendarEventRecordingReconciliationService', () => {
let service: CalendarEventRecordingReconciliationService;
const reconcile = (
meetingAggregates: RealMeetingRecordingAggregate[],
removedOccurrences: RemovedRecordingOccurrence[] = [],
) =>
service.reconcileMeetingOccurrences({
workspaceId: 'workspace-1',
meetingAggregates,
removedOccurrences,
});
beforeEach(() => {
jest.clearAllMocks();
mockGlobalWorkspaceOrmManager.getRepository.mockImplementation(
async (_workspaceId: string, objectName: string) => {
if (objectName === 'calendarEvent') {
return mockCalendarEventRepository;
}
if (objectName === 'callRecording') {
return mockCallRecordingRepository;
}
throw new Error(`Unexpected repository ${objectName}`);
},
);
mockCalendarEventRepository.findOne.mockResolvedValue(buildCalendarEvent());
mockCallRecordingRepository.find.mockResolvedValue([]);
mockCallRecordingRepository.insert.mockResolvedValue({
identifiers: [{ id: 'call-recording-1' }],
});
mockCallRecordingRepository.update.mockResolvedValue({});
mockCallRecordingRepository.updateMany.mockResolvedValue({});
service = new CalendarEventRecordingReconciliationService(
mockGlobalWorkspaceOrmManager as any,
);
});
it('should create a scheduled call recording for an active meeting without an existing lifecycle row', async () => {
const results = await reconcile([buildActiveAggregate()]);
expect(mockCallRecordingRepository.insert).toHaveBeenCalledWith({
title: 'Customer sync',
status: 'SCHEDULED',
startedAt: '2999-01-01T10:00:00.000Z',
endedAt: '2999-01-01T11:00:00.000Z',
calendarEventId: 'event-1',
});
expect(results).toEqual([
{
action: 'CREATED',
realMeetingKey: MEETING_KEY,
callRecordingId: 'call-recording-1',
},
]);
});
it('should update an existing sibling call recording instead of creating a duplicate', async () => {
mockCallRecordingRepository.find.mockResolvedValue([
buildCallRecording({
id: 'call-recording-2',
calendarEventId: 'event-2',
status: 'CANCELED',
}),
]);
const results = await reconcile([
buildActiveAggregate({
calendarEventIds: ['event-1', 'event-2'],
activeCalendarEventIds: ['event-1'],
}),
]);
expect(mockCallRecordingRepository.update).toHaveBeenCalledWith(
'call-recording-2',
{
title: 'Customer sync',
status: 'SCHEDULED',
startedAt: '2999-01-01T10:00:00.000Z',
endedAt: '2999-01-01T11:00:00.000Z',
calendarEventId: 'event-1',
},
);
expect(mockCallRecordingRepository.insert).not.toHaveBeenCalled();
expect(results[0]).toEqual({
action: 'UPDATED',
realMeetingKey: MEETING_KEY,
callRecordingId: 'call-recording-2',
});
});
it('should cancel an existing call recording using the removed calendar event id', async () => {
mockCallRecordingRepository.find.mockResolvedValue([buildCallRecording()]);
const results = await reconcile(
[buildCanceledAggregate({ calendarEventIds: [] })],
[
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
);
expect(mockCallRecordingRepository.updateMany).toHaveBeenCalledWith([
{
criteria: 'call-recording-1',
partialEntity: { status: 'CANCELED' },
},
]);
expect(results[0]).toEqual({
action: 'CANCELED',
realMeetingKey: MEETING_KEY,
callRecordingId: 'call-recording-1',
});
});
it('should leave completed recordings untouched on cancel intent', async () => {
mockCallRecordingRepository.find.mockResolvedValue([
buildCallRecording({ status: 'COMPLETED' }),
]);
const results = await reconcile([buildCanceledAggregate()]);
expect(mockCallRecordingRepository.updateMany).not.toHaveBeenCalled();
expect(results[0]).toEqual({
action: 'SKIPPED',
realMeetingKey: MEETING_KEY,
callRecordingId: null,
});
});
it('should process cancel intents before active intents for a changed meeting key', async () => {
mockCallRecordingRepository.find.mockResolvedValue([buildCallRecording()]);
await reconcile(
[
buildActiveAggregate(),
buildCanceledAggregate({ calendarEventIds: [] }),
],
[
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
);
expect(
mockCallRecordingRepository.updateMany.mock.invocationCallOrder[0],
).toBeLessThan(
mockCallRecordingRepository.update.mock.invocationCallOrder[0],
);
});
});
@@ -0,0 +1,223 @@
import { Injectable } from '@nestjs/common';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type CalendarEventRecordingDecisionResult } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-result.type';
import { type RealMeetingRecordingAggregate } from 'src/modules/calendar/calendar-event-recording-manager/types/real-meeting-recording-aggregate.type';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { aggregateRecordingIntentByMeeting } from 'src/modules/calendar/calendar-event-recording-manager/utils/aggregate-recording-intent-by-meeting.util';
import { buildCalendarEventRecordingDecision } from 'src/modules/calendar/calendar-event-recording-manager/utils/build-calendar-event-recording-decision.util';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
// Stateless: reads calendar events and returns their recording decisions. Nothing is persisted.
@Injectable()
export class CalendarEventRecordingDecisionService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly featureFlagService: FeatureFlagService,
) {}
// Single calendar event, for the QA command. The decision is per-event, not per-meeting.
async evaluateCalendarEvent({
workspaceId,
calendarEventId,
}: {
workspaceId: string;
calendarEventId: string;
}): Promise<CalendarEventRecordingDecisionResult> {
const isRecordingEnabledForWorkspace =
await this.isRecordingEnabledForWorkspace(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarEventRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarEventWorkspaceEntity>(
workspaceId,
'calendarEvent',
);
const calendarEvent = await calendarEventRepository.findOne({
where: { id: calendarEventId },
relations: ['calendarEventParticipants'],
});
if (!isDefined(calendarEvent)) {
return buildNotFoundResult({ workspaceId, calendarEventId });
}
const decision = buildCalendarEventRecordingDecision(calendarEvent, {
isRecordingEnabledForWorkspace,
now: new Date(),
});
return {
workspaceId,
calendarEventId,
found: true,
recordingPreference: decision.recordingPreference,
realMeetingKey: decision.realMeetingKey,
eventIntent: decision.eventIntent,
reason: decision.reason,
};
},
buildSystemAuthContext(workspaceId),
{ lite: true },
);
}
// Re-evaluates every real meeting touched by a change from its CURRENT full set of calendar
// events, so one OFF copy never cancels a meeting another copy still wants recorded. A bot is
// requested when at least one surviving event for the meeting is ACTIVE.
async evaluateMeetingOccurrences({
workspaceId,
calendarEventIds,
removedOccurrences = [],
}: {
workspaceId: string;
calendarEventIds: string[];
removedOccurrences?: RemovedRecordingOccurrence[];
}): Promise<RealMeetingRecordingAggregate[]> {
const isRecordingEnabledForWorkspace =
await this.isRecordingEnabledForWorkspace(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarEventRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarEventWorkspaceEntity>(
workspaceId,
'calendarEvent',
);
const now = new Date();
const changedCalendarEvents =
calendarEventIds.length > 0
? await calendarEventRepository.find({
where: { id: In(calendarEventIds) },
relations: ['calendarEventParticipants'],
})
: [];
const affectedMeetingKeys = new Set<string>();
const occurrenceStartsAtAnchors = new Set<string>();
for (const changedCalendarEvent of changedCalendarEvents) {
affectedMeetingKeys.add(
buildCalendarEventRecordingDecision(changedCalendarEvent, {
isRecordingEnabledForWorkspace,
now,
}).realMeetingKey,
);
if (isDefined(changedCalendarEvent.startsAt)) {
occurrenceStartsAtAnchors.add(changedCalendarEvent.startsAt);
}
}
for (const removedOccurrence of removedOccurrences) {
affectedMeetingKeys.add(removedOccurrence.realMeetingKey);
if (isDefined(removedOccurrence.startsAt)) {
occurrenceStartsAtAnchors.add(removedOccurrence.startsAt);
}
}
if (affectedMeetingKeys.size === 0) {
return [];
}
// Every event sharing an occurrence key also shares its start, so the start is a precise
// anchor for loading all current calendar events that belong to the affected meetings.
const occurrenceSiblingEvents =
occurrenceStartsAtAnchors.size > 0
? await calendarEventRepository.find({
where: { startsAt: In([...occurrenceStartsAtAnchors]) },
relations: ['calendarEventParticipants'],
})
: [];
// A changed event with a null start is not returned by the anchor query; keep it so a
// link-less, iCalUid-less occurrence still evaluates against itself.
const occurrenceEventsById = new Map<
string,
CalendarEventWorkspaceEntity
>();
for (const calendarEvent of [
...occurrenceSiblingEvents,
...changedCalendarEvents,
]) {
occurrenceEventsById.set(calendarEvent.id, calendarEvent);
}
const perEventIntents = [...occurrenceEventsById.values()]
.map((calendarEvent) =>
buildCalendarEventRecordingDecision(calendarEvent, {
isRecordingEnabledForWorkspace,
now,
}),
)
.filter((decision) =>
affectedMeetingKeys.has(decision.realMeetingKey),
)
.map((decision) => ({
calendarEventId: decision.calendarEventId,
realMeetingKey: decision.realMeetingKey,
eventIntent: decision.eventIntent,
}));
const aggregates = aggregateRecordingIntentByMeeting(perEventIntents);
// An occurrence whose every calendar event was removed yields no intent; emit an explicit
// CANCELED aggregate so the dispatcher tears down a bot no event still wants.
const aggregatedMeetingKeys = new Set(
aggregates.map((aggregate) => aggregate.realMeetingKey),
);
for (const meetingKey of affectedMeetingKeys) {
if (!aggregatedMeetingKeys.has(meetingKey)) {
aggregates.push({
realMeetingKey: meetingKey,
providerIntent: 'CANCELED',
calendarEventIds: [],
activeCalendarEventIds: [],
});
}
}
return aggregates;
},
buildSystemAuthContext(workspaceId),
{ lite: true },
);
}
private async isRecordingEnabledForWorkspace(
workspaceId: string,
): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CALL_RECORDING_ENABLED,
workspaceId,
);
}
}
const buildNotFoundResult = ({
workspaceId,
calendarEventId,
}: {
workspaceId: string;
calendarEventId: string;
}): CalendarEventRecordingDecisionResult => ({
workspaceId,
calendarEventId,
found: false,
recordingPreference: null,
realMeetingKey: null,
eventIntent: null,
reason: null,
});
@@ -0,0 +1,296 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type RealMeetingRecordingAggregate } from 'src/modules/calendar/calendar-event-recording-manager/types/real-meeting-recording-aggregate.type';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type CallRecordingWorkspaceEntity } from 'src/modules/call-recording/standard-objects/call-recording.workspace-entity';
const CALL_RECORDING_STATUS = {
SCHEDULED: 'SCHEDULED',
CANCELED: 'CANCELED',
COMPLETED: 'COMPLETED',
} as const;
type ScheduledCallRecordingFields = Pick<
CallRecordingWorkspaceEntity,
'title' | 'status' | 'startedAt' | 'endedAt' | 'calendarEventId'
>;
type CalendarEventRecordingReconciliationAction =
| 'CREATED'
| 'UPDATED'
| 'CANCELED'
| 'SKIPPED';
export type CalendarEventRecordingReconciliationResult = {
action: CalendarEventRecordingReconciliationAction;
realMeetingKey: string;
callRecordingId: string | null;
};
@Injectable()
export class CalendarEventRecordingReconciliationService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async reconcileMeetingOccurrences({
workspaceId,
meetingAggregates,
removedOccurrences = [],
}: {
workspaceId: string;
meetingAggregates: RealMeetingRecordingAggregate[];
removedOccurrences?: RemovedRecordingOccurrence[];
}): Promise<CalendarEventRecordingReconciliationResult[]> {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarEventRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarEventWorkspaceEntity>(
workspaceId,
'calendarEvent',
);
const callRecordingRepository =
await this.globalWorkspaceOrmManager.getRepository<CallRecordingWorkspaceEntity>(
workspaceId,
'callRecording',
);
const removedCalendarEventIdsByMeetingKey =
buildRemovedCalendarEventIdsByMeetingKey(removedOccurrences);
const results: CalendarEventRecordingReconciliationResult[] = [];
for (const aggregate of [
...meetingAggregates.filter(
(meetingAggregate) =>
meetingAggregate.providerIntent === 'CANCELED',
),
...meetingAggregates.filter(
(meetingAggregate) => meetingAggregate.providerIntent === 'ACTIVE',
),
]) {
if (aggregate.providerIntent === 'ACTIVE') {
results.push(
await reconcileActiveMeeting({
aggregate,
calendarEventRepository,
callRecordingRepository,
}),
);
} else {
results.push(
await reconcileCanceledMeeting({
aggregate,
removedCalendarEventIds:
removedCalendarEventIdsByMeetingKey.get(
aggregate.realMeetingKey,
) ?? [],
callRecordingRepository,
}),
);
}
}
return results;
},
buildSystemAuthContext(workspaceId),
{ lite: true },
);
}
}
const reconcileActiveMeeting = async ({
aggregate,
calendarEventRepository,
callRecordingRepository,
}: {
aggregate: RealMeetingRecordingAggregate;
calendarEventRepository: Pick<
WorkspaceRepository<CalendarEventWorkspaceEntity>,
'findOne'
>;
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find' | 'insert' | 'update'
>;
}): Promise<CalendarEventRecordingReconciliationResult> => {
const calendarEventIds = getUniqueSortedCalendarEventIds([
...aggregate.calendarEventIds,
...aggregate.activeCalendarEventIds,
]);
const representativeCalendarEventId = getUniqueSortedCalendarEventIds(
aggregate.activeCalendarEventIds,
)[0];
if (!isDefined(representativeCalendarEventId)) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
const representativeCalendarEvent = await calendarEventRepository.findOne({
where: { id: representativeCalendarEventId },
});
if (!isDefined(representativeCalendarEvent)) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
const existingCallRecording = getFirstActiveLifecycleCallRecording(
await findCallRecordingsByCalendarEventIds({
callRecordingRepository,
calendarEventIds,
}),
);
const callRecordingFields = buildScheduledCallRecordingFields(
representativeCalendarEvent,
);
if (isDefined(existingCallRecording)) {
await callRecordingRepository.update(
existingCallRecording.id,
callRecordingFields,
);
return {
action: 'UPDATED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: existingCallRecording.id,
};
}
const insertResult =
await callRecordingRepository.insert(callRecordingFields);
return {
action: 'CREATED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: insertResult.identifiers[0]?.id ?? null,
};
};
const reconcileCanceledMeeting = async ({
aggregate,
removedCalendarEventIds,
callRecordingRepository,
}: {
aggregate: RealMeetingRecordingAggregate;
removedCalendarEventIds: string[];
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find' | 'updateMany'
>;
}): Promise<CalendarEventRecordingReconciliationResult> => {
const calendarEventIds = getUniqueSortedCalendarEventIds([
...aggregate.calendarEventIds,
...removedCalendarEventIds,
]);
const cancellableCallRecordings = (
await findCallRecordingsByCalendarEventIds({
callRecordingRepository,
calendarEventIds,
})
).filter(
(callRecording) =>
callRecording.status !== CALL_RECORDING_STATUS.COMPLETED &&
callRecording.status !== CALL_RECORDING_STATUS.CANCELED,
);
if (cancellableCallRecordings.length === 0) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
await callRecordingRepository.updateMany(
cancellableCallRecordings.map((callRecording) => ({
criteria: callRecording.id,
partialEntity: { status: CALL_RECORDING_STATUS.CANCELED },
})),
);
return {
action: 'CANCELED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: cancellableCallRecordings[0]?.id ?? null,
};
};
const findCallRecordingsByCalendarEventIds = async ({
callRecordingRepository,
calendarEventIds,
}: {
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find'
>;
calendarEventIds: string[];
}): Promise<CallRecordingWorkspaceEntity[]> => {
if (calendarEventIds.length === 0) {
return [];
}
return callRecordingRepository.find({
where: { calendarEventId: In(calendarEventIds) },
});
};
const buildScheduledCallRecordingFields = (
calendarEvent: CalendarEventWorkspaceEntity,
): ScheduledCallRecordingFields => ({
title: calendarEvent.title,
status: CALL_RECORDING_STATUS.SCHEDULED,
startedAt: calendarEvent.startsAt,
endedAt: calendarEvent.endsAt,
calendarEventId: calendarEvent.id,
});
const buildRemovedCalendarEventIdsByMeetingKey = (
removedOccurrences: RemovedRecordingOccurrence[],
): Map<string, string[]> => {
const calendarEventIdsByMeetingKey = new Map<string, string[]>();
for (const removedOccurrence of removedOccurrences) {
calendarEventIdsByMeetingKey.set(removedOccurrence.realMeetingKey, [
...(calendarEventIdsByMeetingKey.get(removedOccurrence.realMeetingKey) ??
[]),
removedOccurrence.calendarEventId,
]);
}
return calendarEventIdsByMeetingKey;
};
const getFirstActiveLifecycleCallRecording = (
callRecordings: CallRecordingWorkspaceEntity[],
): CallRecordingWorkspaceEntity | undefined =>
[...callRecordings]
.sort((firstCallRecording, secondCallRecording) =>
firstCallRecording.id.localeCompare(secondCallRecording.id),
)
.find(
(callRecording) =>
callRecording.status !== CALL_RECORDING_STATUS.COMPLETED,
);
const getUniqueSortedCalendarEventIds = (calendarEventIds: string[]) =>
[...new Set(calendarEventIds)].sort(
(firstCalendarEventId, secondCalendarEventId) =>
firstCalendarEventId.localeCompare(secondCalendarEventId),
);
@@ -0,0 +1,12 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
// Per-event recording decision for a loaded calendar event, before it is aggregated by meeting.
export type CalendarEventRecordingDecisionForEvent = {
calendarEventId: string;
recordingPreference: string;
realMeetingKey: string;
eventIntent: CalendarEventRecordingIntent;
reason: CalendarEventRecordingDecisionReason;
startsAt: string | null;
};
@@ -0,0 +1,9 @@
export type CalendarEventRecordingDecisionReason =
| 'WORKSPACE_RECORDING_DISABLED'
| 'EVENT_CANCELED'
| 'PREFERENCE_OFF'
| 'PREFERENCE_ON'
| 'AUTO_POLICY_MATCHED'
| 'AUTO_MISSING_CONFERENCE_LINK'
| 'AUTO_EVENT_NOT_UPCOMING'
| 'AUTO_NO_EXTERNAL_PARTICIPANT';
@@ -0,0 +1,12 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingDecisionResult = {
workspaceId: string;
calendarEventId: string;
found: boolean;
recordingPreference: string | null;
realMeetingKey: string | null;
eventIntent: CalendarEventRecordingIntent | null;
reason: CalendarEventRecordingDecisionReason | null;
};
@@ -0,0 +1,7 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingDecision = {
eventIntent: CalendarEventRecordingIntent;
reason: CalendarEventRecordingDecisionReason;
};
@@ -0,0 +1,7 @@
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingIntentForMeeting = {
calendarEventId: string;
realMeetingKey: string;
eventIntent: CalendarEventRecordingIntent;
};
@@ -0,0 +1 @@
export type CalendarEventRecordingIntent = 'ACTIVE' | 'CANCELED';
@@ -0,0 +1,9 @@
// Decoupled from the workspace entity so the policy stays a pure, unit-testable function.
export type CalendarEventRecordingPolicyInput = {
recordingPreference: string;
isCanceled: boolean;
startsAt: string | null;
endsAt: string | null;
conferenceLinkUrl: string | null;
hasExternalParticipant: boolean;
};
@@ -0,0 +1,8 @@
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type RealMeetingRecordingAggregate = {
realMeetingKey: string;
providerIntent: CalendarEventRecordingIntent;
calendarEventIds: string[];
activeCalendarEventIds: string[];
};
@@ -0,0 +1,7 @@
// A real meeting occurrence whose source calendar event was deleted. The key + start let the job
// re-check the surviving calendar events for that occurrence and cancel a bot none of them want.
export type RemovedRecordingOccurrence = {
calendarEventId: string;
realMeetingKey: string;
startsAt: string | null;
};
@@ -0,0 +1,95 @@
import { aggregateRecordingIntentByMeeting } from 'src/modules/calendar/calendar-event-recording-manager/utils/aggregate-recording-intent-by-meeting.util';
const MEETING_KEY = 'link:meet.google.com/abc-defg-hij';
describe('aggregateRecordingIntentByMeeting', () => {
it('should produce one ACTIVE aggregate when two events for the same meeting both want recording', () => {
const aggregates = aggregateRecordingIntentByMeeting([
{
calendarEventId: 'event-a',
realMeetingKey: MEETING_KEY,
eventIntent: 'ACTIVE',
},
{
calendarEventId: 'event-b',
realMeetingKey: MEETING_KEY,
eventIntent: 'ACTIVE',
},
]);
expect(aggregates).toEqual([
{
realMeetingKey: MEETING_KEY,
providerIntent: 'ACTIVE',
calendarEventIds: ['event-a', 'event-b'],
activeCalendarEventIds: ['event-a', 'event-b'],
},
]);
});
it('should keep one ACTIVE aggregate when one event is ON and its duplicate is OFF', () => {
const aggregates = aggregateRecordingIntentByMeeting([
{
calendarEventId: 'event-a',
realMeetingKey: MEETING_KEY,
eventIntent: 'ACTIVE',
},
{
calendarEventId: 'event-b',
realMeetingKey: MEETING_KEY,
eventIntent: 'CANCELED',
},
]);
expect(aggregates).toHaveLength(1);
expect(aggregates[0].providerIntent).toBe('ACTIVE');
expect(aggregates[0].activeCalendarEventIds).toEqual(['event-a']);
expect(aggregates[0].calendarEventIds).toEqual(['event-a', 'event-b']);
});
it('should cancel the aggregate when no event for the meeting wants recording', () => {
const aggregates = aggregateRecordingIntentByMeeting([
{
calendarEventId: 'event-a',
realMeetingKey: MEETING_KEY,
eventIntent: 'CANCELED',
},
{
calendarEventId: 'event-b',
realMeetingKey: MEETING_KEY,
eventIntent: 'CANCELED',
},
]);
expect(aggregates).toHaveLength(1);
expect(aggregates[0].providerIntent).toBe('CANCELED');
expect(aggregates[0].activeCalendarEventIds).toEqual([]);
});
it('should aggregate separate meetings independently', () => {
const aggregates = aggregateRecordingIntentByMeeting([
{
calendarEventId: 'event-a',
realMeetingKey: 'link:meeting-1',
eventIntent: 'ACTIVE',
},
{
calendarEventId: 'event-b',
realMeetingKey: 'link:meeting-2',
eventIntent: 'CANCELED',
},
]);
expect(aggregates).toHaveLength(2);
expect(
aggregates.find(
(aggregate) => aggregate.realMeetingKey === 'link:meeting-1',
)?.providerIntent,
).toBe('ACTIVE');
expect(
aggregates.find(
(aggregate) => aggregate.realMeetingKey === 'link:meeting-2',
)?.providerIntent,
).toBe('CANCELED');
});
});
@@ -0,0 +1,90 @@
import { computeRealMeetingKey } from 'src/modules/calendar/calendar-event-recording-manager/utils/compute-real-meeting-key.util';
describe('computeRealMeetingKey', () => {
it('should key on the normalized conference link and occurrence start when present', () => {
expect(
computeRealMeetingKey({
calendarEventId: 'event-1',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
iCalUid: 'ical-1',
startsAt: '2026-06-05T11:00:00.000Z',
}),
).toBe('link:meet.google.com/abc-defg-hij:2026-06-05T11:00:00.000Z');
});
it('should produce the same key for two events sharing one meeting link at the same start', () => {
const keyA = computeRealMeetingKey({
calendarEventId: 'event-a',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij?authuser=0',
iCalUid: 'ical-a',
startsAt: '2026-06-05T11:00:00.000Z',
});
const keyB = computeRealMeetingKey({
calendarEventId: 'event-b',
conferenceLinkUrl: 'http://www.meet.google.com/abc-defg-hij/',
iCalUid: 'ical-b',
startsAt: '2026-06-05T11:00:00.000Z',
});
expect(keyA).toBe(keyB);
});
it('should separate recurring occurrences that reuse the same meeting link', () => {
const firstWeek = computeRealMeetingKey({
calendarEventId: 'event-a',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
iCalUid: 'ical-a',
startsAt: '2026-06-05T11:00:00.000Z',
});
const secondWeek = computeRealMeetingKey({
calendarEventId: 'event-b',
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
iCalUid: 'ical-a',
startsAt: '2026-06-12T11:00:00.000Z',
});
expect(firstWeek).not.toBe(secondWeek);
});
it('should fall back to iCalUid and occurrence start when there is no link', () => {
expect(
computeRealMeetingKey({
calendarEventId: 'event-1',
conferenceLinkUrl: null,
iCalUid: 'recurring-uid@google.com',
startsAt: '2026-06-05T11:00:00.000Z',
}),
).toBe('ical:recurring-uid@google.com:2026-06-05T11:00:00.000Z');
});
it('should separate recurring occurrences by start time', () => {
const firstOccurrence = computeRealMeetingKey({
calendarEventId: 'event-1',
conferenceLinkUrl: null,
iCalUid: 'recurring-uid@google.com',
startsAt: '2026-06-05T11:00:00.000Z',
});
const secondOccurrence = computeRealMeetingKey({
calendarEventId: 'event-2',
conferenceLinkUrl: null,
iCalUid: 'recurring-uid@google.com',
startsAt: '2026-06-12T11:00:00.000Z',
});
expect(firstOccurrence).not.toBe(secondOccurrence);
});
it('should fall back to the event id when there is no shared meeting identity', () => {
expect(
computeRealMeetingKey({
calendarEventId: 'event-1',
conferenceLinkUrl: null,
iCalUid: null,
startsAt: null,
}),
).toBe('event:event-1');
});
});
@@ -0,0 +1,123 @@
import { type CalendarEventRecordingPolicyInput } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-policy-input.type';
import { evaluateCalendarEventRecordingDecision } from 'src/modules/calendar/calendar-event-recording-manager/utils/evaluate-calendar-event-recording-decision.util';
const NOW = new Date('2026-06-05T10:00:00.000Z');
const FUTURE = '2026-06-05T11:00:00.000Z';
const PAST = '2026-06-05T09:00:00.000Z';
const buildPolicyInput = (
overrides: Partial<CalendarEventRecordingPolicyInput> = {},
): CalendarEventRecordingPolicyInput => ({
recordingPreference: 'AUTO',
isCanceled: false,
startsAt: FUTURE,
endsAt: FUTURE,
conferenceLinkUrl: 'https://meet.google.com/abc-defg-hij',
hasExternalParticipant: true,
...overrides,
});
const evaluate = (
input: CalendarEventRecordingPolicyInput,
isRecordingEnabledForWorkspace = true,
) =>
evaluateCalendarEventRecordingDecision({
input,
now: NOW,
isRecordingEnabledForWorkspace,
});
describe('evaluateCalendarEventRecordingDecision', () => {
describe('AUTO preference', () => {
it('should request recording when AUTO matches every policy condition', () => {
expect(evaluate(buildPolicyInput())).toEqual({
eventIntent: 'ACTIVE',
reason: 'AUTO_POLICY_MATCHED',
});
});
it('should not request recording when AUTO has no conference link', () => {
expect(evaluate(buildPolicyInput({ conferenceLinkUrl: null }))).toEqual({
eventIntent: 'CANCELED',
reason: 'AUTO_MISSING_CONFERENCE_LINK',
});
});
it('should not request recording when AUTO event already ended', () => {
expect(
evaluate(buildPolicyInput({ startsAt: PAST, endsAt: PAST })),
).toEqual({
eventIntent: 'CANCELED',
reason: 'AUTO_EVENT_NOT_UPCOMING',
});
});
it('should not request recording when AUTO event has no external participant', () => {
expect(
evaluate(buildPolicyInput({ hasExternalParticipant: false })),
).toEqual({
eventIntent: 'CANCELED',
reason: 'AUTO_NO_EXTERNAL_PARTICIPANT',
});
});
it('should treat an unknown preference as AUTO', () => {
expect(
evaluate(buildPolicyInput({ recordingPreference: 'SOMETHING_ELSE' })),
).toEqual({
eventIntent: 'ACTIVE',
reason: 'AUTO_POLICY_MATCHED',
});
});
it('should keep an in-progress meeting eligible via its end time', () => {
expect(
evaluate(buildPolicyInput({ startsAt: PAST, endsAt: FUTURE })),
).toEqual({
eventIntent: 'ACTIVE',
reason: 'AUTO_POLICY_MATCHED',
});
});
});
describe('ON preference', () => {
it('should request recording even when the AUTO policy would not match', () => {
expect(
evaluate(
buildPolicyInput({
recordingPreference: 'ON',
conferenceLinkUrl: null,
hasExternalParticipant: false,
}),
),
).toEqual({ eventIntent: 'ACTIVE', reason: 'PREFERENCE_ON' });
});
});
describe('OFF preference', () => {
it('should cancel recording intent for this event', () => {
expect(
evaluate(buildPolicyInput({ recordingPreference: 'OFF' })),
).toEqual({ eventIntent: 'CANCELED', reason: 'PREFERENCE_OFF' });
});
});
describe('global guards', () => {
it('should cancel intent when recording is disabled for the workspace, ignoring ON', () => {
expect(
evaluate(buildPolicyInput({ recordingPreference: 'ON' }), false),
).toEqual({
eventIntent: 'CANCELED',
reason: 'WORKSPACE_RECORDING_DISABLED',
});
});
it('should cancel intent for a canceled event, ignoring ON', () => {
expect(
evaluate(
buildPolicyInput({ recordingPreference: 'ON', isCanceled: true }),
),
).toEqual({ eventIntent: 'CANCELED', reason: 'EVENT_CANCELED' });
});
});
});
@@ -0,0 +1,38 @@
import { type CalendarEventRecordingIntentForMeeting } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent-for-meeting.type';
import { type RealMeetingRecordingAggregate } from 'src/modules/calendar/calendar-event-recording-manager/types/real-meeting-recording-aggregate.type';
// Collapses many per-event intents into one decision per real meeting: a bot is requested when at
// least one calendar event for that meeting is ACTIVE, so duplicate calendar rows never produce
// duplicate bots. The provider-dispatch PR turns each aggregate into one idempotent bot upsert.
export const aggregateRecordingIntentByMeeting = (
perEventIntents: CalendarEventRecordingIntentForMeeting[],
): RealMeetingRecordingAggregate[] => {
const aggregatesByMeetingKey = new Map<
string,
RealMeetingRecordingAggregate
>();
for (const {
calendarEventId,
realMeetingKey,
eventIntent,
} of perEventIntents) {
const aggregate = aggregatesByMeetingKey.get(realMeetingKey) ?? {
realMeetingKey,
providerIntent: 'CANCELED',
calendarEventIds: [],
activeCalendarEventIds: [],
};
aggregate.calendarEventIds.push(calendarEventId);
if (eventIntent === 'ACTIVE') {
aggregate.providerIntent = 'ACTIVE';
aggregate.activeCalendarEventIds.push(calendarEventId);
}
aggregatesByMeetingKey.set(realMeetingKey, aggregate);
}
return [...aggregatesByMeetingKey.values()];
};
@@ -0,0 +1,55 @@
import { isDefined } from 'twenty-shared/utils';
import { type CalendarEventRecordingDecisionForEvent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-for-event.type';
import { computeRealMeetingKey } from 'src/modules/calendar/calendar-event-recording-manager/utils/compute-real-meeting-key.util';
import { evaluateCalendarEventRecordingDecision } from 'src/modules/calendar/calendar-event-recording-manager/utils/evaluate-calendar-event-recording-decision.util';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
type BuildCalendarEventRecordingDecisionContext = {
isRecordingEnabledForWorkspace: boolean;
now: Date;
};
export const buildCalendarEventRecordingDecision = (
calendarEvent: CalendarEventWorkspaceEntity,
{
isRecordingEnabledForWorkspace,
now,
}: BuildCalendarEventRecordingDecisionContext,
): CalendarEventRecordingDecisionForEvent => {
const conferenceLinkUrl =
calendarEvent.conferenceLink?.primaryLinkUrl ?? null;
const realMeetingKey = computeRealMeetingKey({
calendarEventId: calendarEvent.id,
conferenceLinkUrl,
iCalUid: calendarEvent.iCalUid,
startsAt: calendarEvent.startsAt,
});
const hasExternalParticipant = (
calendarEvent.calendarEventParticipants ?? []
).some((participant) => !isDefined(participant.workspaceMemberId));
const { eventIntent, reason } = evaluateCalendarEventRecordingDecision({
input: {
recordingPreference: calendarEvent.recordingPreference,
isCanceled: calendarEvent.isCanceled,
startsAt: calendarEvent.startsAt,
endsAt: calendarEvent.endsAt,
conferenceLinkUrl,
hasExternalParticipant,
},
now,
isRecordingEnabledForWorkspace,
});
return {
calendarEventId: calendarEvent.id,
recordingPreference: calendarEvent.recordingPreference,
realMeetingKey,
eventIntent,
reason,
startsAt: calendarEvent.startsAt,
};
};
@@ -0,0 +1,52 @@
import { isDefined } from 'twenty-shared/utils';
type ComputeRealMeetingKeyInput = {
calendarEventId: string;
conferenceLinkUrl: string | null;
iCalUid: string | null;
startsAt: string | null;
};
// Identifies the real meeting occurrence so duplicate calendar events (same meeting synced
// through several channels) dedupe to one bot. Never keyed primarily on the calendar event id.
export const computeRealMeetingKey = ({
calendarEventId,
conferenceLinkUrl,
iCalUid,
startsAt,
}: ComputeRealMeetingKeyInput): string => {
const normalizedConferenceLink = normalizeConferenceLink(conferenceLinkUrl);
if (isDefined(normalizedConferenceLink)) {
// Same link + same start = one occurrence synced to several calendars (dedupe to one bot);
// same link + different start = distinct recurring occurrences sharing a permalink (keep apart).
return `link:${normalizedConferenceLink}:${startsAt ?? ''}`;
}
if (isDefined(iCalUid) && iCalUid !== '') {
// Recurring instances share an iCalUid; the occurrence start separates them.
return `ical:${iCalUid}:${startsAt ?? ''}`;
}
// Last resort: no shared meeting identity, so this event can only dedupe against itself.
return `event:${calendarEventId}`;
};
const normalizeConferenceLink = (
conferenceLinkUrl: string | null,
): string | null => {
if (!isDefined(conferenceLinkUrl) || conferenceLinkUrl.trim() === '') {
return null;
}
const withoutProtocol = conferenceLinkUrl
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '');
const withoutQueryAndFragment = withoutProtocol.split(/[?#]/)[0];
const withoutTrailingSlash = withoutQueryAndFragment.replace(/\/+$/, '');
return withoutTrailingSlash === '' ? null : withoutTrailingSlash;
};
@@ -0,0 +1,90 @@
import { isDefined } from 'twenty-shared/utils';
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingDecision } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision.type';
import { type CalendarEventRecordingPolicyInput } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-policy-input.type';
type EvaluateCalendarEventRecordingDecisionArgs = {
input: CalendarEventRecordingPolicyInput;
now: Date;
isRecordingEnabledForWorkspace: boolean;
};
export const evaluateCalendarEventRecordingDecision = ({
input,
now,
isRecordingEnabledForWorkspace,
}: EvaluateCalendarEventRecordingDecisionArgs): CalendarEventRecordingDecision => {
if (!isRecordingEnabledForWorkspace) {
return cancel('WORKSPACE_RECORDING_DISABLED');
}
if (input.isCanceled) {
return cancel('EVENT_CANCELED');
}
if (input.recordingPreference === 'OFF') {
return cancel('PREFERENCE_OFF');
}
if (input.recordingPreference === 'ON') {
return active('PREFERENCE_ON');
}
// AUTO (default): narrow external-meetings policy. Every condition must hold.
if (
!isDefined(input.conferenceLinkUrl) ||
input.conferenceLinkUrl.trim() === ''
) {
return cancel('AUTO_MISSING_CONFERENCE_LINK');
}
if (
!isCalendarEventInFuture({
startsAt: input.startsAt,
endsAt: input.endsAt,
now,
})
) {
return cancel('AUTO_EVENT_NOT_UPCOMING');
}
if (!input.hasExternalParticipant) {
return cancel('AUTO_NO_EXTERNAL_PARTICIPANT');
}
return active('AUTO_POLICY_MATCHED');
};
const isCalendarEventInFuture = ({
startsAt,
endsAt,
now,
}: {
startsAt: string | null;
endsAt: string | null;
now: Date;
}): boolean => {
// Prefer the end time so an in-progress meeting is still eligible; fall back to start.
const reference = endsAt ?? startsAt;
if (!isDefined(reference) || reference === '') {
return false;
}
const referenceTime = new Date(reference).getTime();
if (Number.isNaN(referenceTime)) {
return false;
}
return referenceTime > now.getTime();
};
const active = (
reason: CalendarEventRecordingDecisionReason,
): CalendarEventRecordingDecision => ({ eventIntent: 'ACTIVE', reason });
const cancel = (
reason: CalendarEventRecordingDecisionReason,
): CalendarEventRecordingDecision => ({ eventIntent: 'CANCELED', reason });
@@ -4,6 +4,7 @@ import { CalendarBlocklistManagerModule } from 'src/modules/calendar/blocklist-m
import { CalendarEventCleanerModule } from 'src/modules/calendar/calendar-event-cleaner/calendar-event-cleaner.module';
import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module';
import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module';
import { CalendarEventRecordingManagerModule } from 'src/modules/calendar/calendar-event-recording-manager/calendar-event-recording-manager.module';
import { CalendarCommonModule } from 'src/modules/calendar/common/calendar-common.module';
@Module({
@@ -12,6 +13,7 @@ import { CalendarCommonModule } from 'src/modules/calendar/common/calendar-commo
CalendarEventCleanerModule,
CalendarEventImportManagerModule,
CalendarEventParticipantManagerModule,
CalendarEventRecordingManagerModule,
CalendarCommonModule,
],
providers: [],