Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6d384e9b | ||
|
|
62dce10699 | ||
|
|
a81dd55f9d | ||
|
|
22f9c3517b | ||
|
|
c629ea858b | ||
|
|
05ea7ac120 | ||
|
|
0c1ca75981 | ||
|
|
1f63ff0f3d | ||
|
|
cc93aaa6b4 | ||
|
|
312b6969aa | ||
|
|
dd46d74d41 | ||
|
|
1118c40864 | ||
|
|
817cf18075 | ||
|
|
544ebd487a | ||
|
|
5672a090f8 | ||
|
|
8a8310dee3 | ||
|
|
97c6b3a4b8 | ||
|
|
8ce69d52ab | ||
|
|
9f76d44c22 | ||
|
|
e36d6290ea | ||
|
|
33f3b4fbf9 | ||
|
|
d8d02cb388 | ||
|
|
73e619174f | ||
|
|
426f87dfe6 | ||
|
|
807ef86907 | ||
|
|
7e7ff345da | ||
|
|
69219d8871 | ||
|
|
7a60706437 | ||
|
|
62473fc5ea | ||
|
|
ff7819a353 | ||
|
|
94badfd1c6 | ||
|
|
9a8e3e8bc6 | ||
|
|
0a510a68de | ||
|
|
bfe1046af4 | ||
|
|
be9b7a5222 | ||
|
|
f770acbdee | ||
|
|
c68ff85f49 | ||
|
|
7cc89c0811 | ||
|
|
81da1bc2ac | ||
|
|
c2d3edb0f6 | ||
|
|
c821869f9e | ||
|
|
c36c1c7675 |
+18
@@ -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 {}
|
||||
+142
@@ -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;
|
||||
+2
@@ -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 {}
|
||||
|
||||
+8
-1
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
+26
@@ -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 {}
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
+50
@@ -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'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -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: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+114
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+112
@@ -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: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
@@ -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,
|
||||
});
|
||||
+219
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
+230
@@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
+223
@@ -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,
|
||||
});
|
||||
+296
@@ -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),
|
||||
);
|
||||
+12
@@ -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;
|
||||
};
|
||||
+9
@@ -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';
|
||||
+12
@@ -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;
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type CalendarEventRecordingIntent = 'ACTIVE' | 'CANCELED';
|
||||
+9
@@ -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;
|
||||
};
|
||||
+8
@@ -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[];
|
||||
};
|
||||
+7
@@ -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;
|
||||
};
|
||||
+95
@@ -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');
|
||||
});
|
||||
});
|
||||
+90
@@ -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');
|
||||
});
|
||||
});
|
||||
+123
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
+38
@@ -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()];
|
||||
};
|
||||
+55
@@ -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,
|
||||
};
|
||||
};
|
||||
+52
@@ -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;
|
||||
};
|
||||
+90
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user