add upgrade command

This commit is contained in:
ehconitin
2026-06-05 21:28:38 +05:30
parent 62dce10699
commit 9e6d384e9b
26 changed files with 864 additions and 108 deletions
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SyncCallRecordingCanceledStatusCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-workspace-command-1799000060000-sync-call-recording-canceled-status.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [SyncCallRecordingCanceledStatusCommand],
})
export class V2_11_UpgradeVersionCommandModule {}
@@ -0,0 +1,142 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier;
const CANCELED_CALL_RECORDING_STATUS = 'CANCELED';
@RegisteredWorkspaceCommand('2.11.0', 1799000060000)
@Command({
name: 'upgrade:2-11:sync-call-recording-canceled-status',
description:
'Add the CANCELED option to the CallRecording status field in existing workspaces',
})
export class SyncCallRecordingCanceledStatusCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const existingStatusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingStatusField)) {
this.logger.log(
`CallRecording status field does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
if (hasCanceledOption(existingStatusField)) {
this.logger.log(
`CallRecording status field already includes ${CANCELED_CALL_RECORDING_STATUS} for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const standardStatusField =
standardAllFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (!isDefined(standardStatusField?.options)) {
throw new Error(
`Standard CallRecording status field options not found for workspace ${workspaceId}`,
);
}
const statusFieldToUpdate: FlatFieldMetadata = {
...existingStatusField,
options: standardStatusField.options,
updatedAt: new Date().toISOString(),
};
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Adding ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [statusFieldToUpdate],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to add ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Added ${CANCELED_CALL_RECORDING_STATUS} to CallRecording status field for workspace ${workspaceId}`,
);
}
}
const hasCanceledOption = (fieldMetadata: FlatFieldMetadata): boolean =>
fieldMetadata.options?.some(
(option) => option.value === CANCELED_CALL_RECORDING_STATUS,
) === true;
@@ -13,6 +13,7 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_11_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-11/2-11-upgrade-version-command.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_8_UpgradeVersionCommandModule,
V2_9_UpgradeVersionCommandModule,
V2_10_UpgradeVersionCommandModule,
V2_11_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -170,11 +170,18 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
position: 4,
color: 'green',
},
{
id: '28c6019d-e543-4d5a-a0b0-745999450270',
value: 'CANCELED',
label: i18nLabel(msg`Canceled`),
position: 5,
color: 'gray',
},
{
id: '4800777e-54a8-4464-9c01-07d6eefd04da',
value: 'FAILED_UNKNOWN',
label: i18nLabel(msg`Failed`),
position: 5,
position: 6,
color: 'gray',
},
],
@@ -6,16 +6,21 @@ import { CalendarEventRecordingDecisionJob } from 'src/modules/calendar/calendar
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],
exports: [
CalendarEventRecordingDecisionService,
CalendarEventRecordingReconciliationService,
],
})
export class CalendarEventRecordingManagerModule {}
@@ -3,8 +3,9 @@ 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/calendar-event-recording.types';
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;
@@ -21,6 +22,7 @@ export class CalendarEventRecordingDecisionJob {
constructor(
private readonly calendarEventRecordingDecisionService: CalendarEventRecordingDecisionService,
private readonly calendarEventRecordingReconciliationService: CalendarEventRecordingReconciliationService,
) {}
@Process(CalendarEventRecordingDecisionJob.name)
@@ -34,17 +36,15 @@ export class CalendarEventRecordingDecisionJob {
{ workspaceId, calendarEventIds, removedOccurrences },
);
// No bot is dispatched yet; the per-meeting decision is only logged.
for (const aggregate of meetingAggregates) {
if (aggregate.providerIntent === 'ACTIVE') {
this.logger.log(
`would request bot for meeting ${aggregate.realMeetingKey} in workspace ${workspaceId} (calendar events: ${aggregate.activeCalendarEventIds.join(', ')})`,
);
} else {
this.logger.log(
`would cancel intent for meeting ${aggregate.realMeetingKey} in workspace ${workspaceId}`,
);
}
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'}`,
);
}
}
}
@@ -5,10 +5,24 @@ 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 } }],
events: [
{
recordId: 'event-1',
properties: { updatedFields, before: OLD_CALENDAR_EVENT },
},
],
}) as any;
describe('CalendarEventRecordingListener', () => {
@@ -36,10 +50,24 @@ describe('CalendarEventRecordingListener', () => {
);
});
it('should enqueue when the composite conferenceLink changed (surfaces under its parent name)', async () => {
it('should enqueue the current event and previous occurrence when the meeting key changed', async () => {
await listener.handleUpdatedEvent(buildUpdatePayload(['conferenceLink']));
expect(mockMessageQueueService.add).toHaveBeenCalledTimes(1);
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 () => {
@@ -74,6 +102,7 @@ describe('CalendarEventRecordingListener', () => {
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',
@@ -17,7 +17,7 @@ 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/calendar-event-recording.types';
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';
@@ -33,6 +33,12 @@ const RECORDING_RELEVANT_CALENDAR_EVENT_FIELDS = [
'iCalUid',
];
const RECORDING_KEY_CALENDAR_EVENT_FIELDS = [
'conferenceLink',
'startsAt',
'iCalUid',
];
@Injectable()
export class CalendarEventRecordingListener {
constructor(
@@ -58,15 +64,22 @@ export class CalendarEventRecordingListener {
ObjectRecordUpdateEvent<CalendarEventWorkspaceEntity>
>,
): Promise<void> {
const calendarEventIds = payload.events
const recordingRelevantEvents = payload.events.filter((event) =>
hasRecordingRelevantFieldChange(event.properties.updatedFields),
);
const calendarEventIds = recordingRelevantEvents.map(
(event) => event.recordId,
);
const removedOccurrences = recordingRelevantEvents
.filter((event) =>
hasRecordingRelevantFieldChange(event.properties.updatedFields),
hasRecordingKeyFieldChange(event.properties.updatedFields),
)
.map((event) => event.recordId);
.map((event) => buildRemovedOccurrence(event.properties.before));
await this.enqueueDecision({
workspaceId: payload.workspaceId,
calendarEventIds,
removedOccurrences,
});
}
@@ -127,9 +140,17 @@ const hasRecordingRelevantFieldChange = (
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,
@@ -171,7 +171,11 @@ describe('CalendarEventRecordingDecisionService', () => {
workspaceId: 'workspace-1',
calendarEventIds: [],
removedOccurrences: [
{ realMeetingKey: MEETING_KEY, startsAt: '2999-01-01T10:00:00.000Z' },
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
});
@@ -199,7 +203,11 @@ describe('CalendarEventRecordingDecisionService', () => {
workspaceId: 'workspace-1',
calendarEventIds: [],
removedOccurrences: [
{ realMeetingKey: MEETING_KEY, startsAt: '2999-01-01T10:00:00.000Z' },
{
calendarEventId: 'event-1',
realMeetingKey: MEETING_KEY,
startsAt: '2999-01-01T10:00:00.000Z',
},
],
});
@@ -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],
);
});
});
@@ -7,14 +7,10 @@ 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,
type RemovedRecordingOccurrence,
} from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording.types';
import {
aggregateRecordingIntentByMeeting,
type RealMeetingRecordingAggregate,
} from 'src/modules/calendar/calendar-event-recording-manager/utils/aggregate-recording-intent-by-meeting.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';
@@ -0,0 +1,296 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type RealMeetingRecordingAggregate } from 'src/modules/calendar/calendar-event-recording-manager/types/real-meeting-recording-aggregate.type';
import { type RemovedRecordingOccurrence } from 'src/modules/calendar/calendar-event-recording-manager/types/removed-recording-occurrence.type';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type CallRecordingWorkspaceEntity } from 'src/modules/call-recording/standard-objects/call-recording.workspace-entity';
const CALL_RECORDING_STATUS = {
SCHEDULED: 'SCHEDULED',
CANCELED: 'CANCELED',
COMPLETED: 'COMPLETED',
} as const;
type ScheduledCallRecordingFields = Pick<
CallRecordingWorkspaceEntity,
'title' | 'status' | 'startedAt' | 'endedAt' | 'calendarEventId'
>;
type CalendarEventRecordingReconciliationAction =
| 'CREATED'
| 'UPDATED'
| 'CANCELED'
| 'SKIPPED';
export type CalendarEventRecordingReconciliationResult = {
action: CalendarEventRecordingReconciliationAction;
realMeetingKey: string;
callRecordingId: string | null;
};
@Injectable()
export class CalendarEventRecordingReconciliationService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async reconcileMeetingOccurrences({
workspaceId,
meetingAggregates,
removedOccurrences = [],
}: {
workspaceId: string;
meetingAggregates: RealMeetingRecordingAggregate[];
removedOccurrences?: RemovedRecordingOccurrence[];
}): Promise<CalendarEventRecordingReconciliationResult[]> {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarEventRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarEventWorkspaceEntity>(
workspaceId,
'calendarEvent',
);
const callRecordingRepository =
await this.globalWorkspaceOrmManager.getRepository<CallRecordingWorkspaceEntity>(
workspaceId,
'callRecording',
);
const removedCalendarEventIdsByMeetingKey =
buildRemovedCalendarEventIdsByMeetingKey(removedOccurrences);
const results: CalendarEventRecordingReconciliationResult[] = [];
for (const aggregate of [
...meetingAggregates.filter(
(meetingAggregate) =>
meetingAggregate.providerIntent === 'CANCELED',
),
...meetingAggregates.filter(
(meetingAggregate) => meetingAggregate.providerIntent === 'ACTIVE',
),
]) {
if (aggregate.providerIntent === 'ACTIVE') {
results.push(
await reconcileActiveMeeting({
aggregate,
calendarEventRepository,
callRecordingRepository,
}),
);
} else {
results.push(
await reconcileCanceledMeeting({
aggregate,
removedCalendarEventIds:
removedCalendarEventIdsByMeetingKey.get(
aggregate.realMeetingKey,
) ?? [],
callRecordingRepository,
}),
);
}
}
return results;
},
buildSystemAuthContext(workspaceId),
{ lite: true },
);
}
}
const reconcileActiveMeeting = async ({
aggregate,
calendarEventRepository,
callRecordingRepository,
}: {
aggregate: RealMeetingRecordingAggregate;
calendarEventRepository: Pick<
WorkspaceRepository<CalendarEventWorkspaceEntity>,
'findOne'
>;
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find' | 'insert' | 'update'
>;
}): Promise<CalendarEventRecordingReconciliationResult> => {
const calendarEventIds = getUniqueSortedCalendarEventIds([
...aggregate.calendarEventIds,
...aggregate.activeCalendarEventIds,
]);
const representativeCalendarEventId = getUniqueSortedCalendarEventIds(
aggregate.activeCalendarEventIds,
)[0];
if (!isDefined(representativeCalendarEventId)) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
const representativeCalendarEvent = await calendarEventRepository.findOne({
where: { id: representativeCalendarEventId },
});
if (!isDefined(representativeCalendarEvent)) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
const existingCallRecording = getFirstActiveLifecycleCallRecording(
await findCallRecordingsByCalendarEventIds({
callRecordingRepository,
calendarEventIds,
}),
);
const callRecordingFields = buildScheduledCallRecordingFields(
representativeCalendarEvent,
);
if (isDefined(existingCallRecording)) {
await callRecordingRepository.update(
existingCallRecording.id,
callRecordingFields,
);
return {
action: 'UPDATED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: existingCallRecording.id,
};
}
const insertResult =
await callRecordingRepository.insert(callRecordingFields);
return {
action: 'CREATED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: insertResult.identifiers[0]?.id ?? null,
};
};
const reconcileCanceledMeeting = async ({
aggregate,
removedCalendarEventIds,
callRecordingRepository,
}: {
aggregate: RealMeetingRecordingAggregate;
removedCalendarEventIds: string[];
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find' | 'updateMany'
>;
}): Promise<CalendarEventRecordingReconciliationResult> => {
const calendarEventIds = getUniqueSortedCalendarEventIds([
...aggregate.calendarEventIds,
...removedCalendarEventIds,
]);
const cancellableCallRecordings = (
await findCallRecordingsByCalendarEventIds({
callRecordingRepository,
calendarEventIds,
})
).filter(
(callRecording) =>
callRecording.status !== CALL_RECORDING_STATUS.COMPLETED &&
callRecording.status !== CALL_RECORDING_STATUS.CANCELED,
);
if (cancellableCallRecordings.length === 0) {
return {
action: 'SKIPPED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: null,
};
}
await callRecordingRepository.updateMany(
cancellableCallRecordings.map((callRecording) => ({
criteria: callRecording.id,
partialEntity: { status: CALL_RECORDING_STATUS.CANCELED },
})),
);
return {
action: 'CANCELED',
realMeetingKey: aggregate.realMeetingKey,
callRecordingId: cancellableCallRecordings[0]?.id ?? null,
};
};
const findCallRecordingsByCalendarEventIds = async ({
callRecordingRepository,
calendarEventIds,
}: {
callRecordingRepository: Pick<
WorkspaceRepository<CallRecordingWorkspaceEntity>,
'find'
>;
calendarEventIds: string[];
}): Promise<CallRecordingWorkspaceEntity[]> => {
if (calendarEventIds.length === 0) {
return [];
}
return callRecordingRepository.find({
where: { calendarEventId: In(calendarEventIds) },
});
};
const buildScheduledCallRecordingFields = (
calendarEvent: CalendarEventWorkspaceEntity,
): ScheduledCallRecordingFields => ({
title: calendarEvent.title,
status: CALL_RECORDING_STATUS.SCHEDULED,
startedAt: calendarEvent.startsAt,
endedAt: calendarEvent.endsAt,
calendarEventId: calendarEvent.id,
});
const buildRemovedCalendarEventIdsByMeetingKey = (
removedOccurrences: RemovedRecordingOccurrence[],
): Map<string, string[]> => {
const calendarEventIdsByMeetingKey = new Map<string, string[]>();
for (const removedOccurrence of removedOccurrences) {
calendarEventIdsByMeetingKey.set(removedOccurrence.realMeetingKey, [
...(calendarEventIdsByMeetingKey.get(removedOccurrence.realMeetingKey) ??
[]),
removedOccurrence.calendarEventId,
]);
}
return calendarEventIdsByMeetingKey;
};
const getFirstActiveLifecycleCallRecording = (
callRecordings: CallRecordingWorkspaceEntity[],
): CallRecordingWorkspaceEntity | undefined =>
[...callRecordings]
.sort((firstCallRecording, secondCallRecording) =>
firstCallRecording.id.localeCompare(secondCallRecording.id),
)
.find(
(callRecording) =>
callRecording.status !== CALL_RECORDING_STATUS.COMPLETED,
);
const getUniqueSortedCalendarEventIds = (calendarEventIds: string[]) =>
[...new Set(calendarEventIds)].sort(
(firstCalendarEventId, secondCalendarEventId) =>
firstCalendarEventId.localeCompare(secondCalendarEventId),
);
@@ -0,0 +1,12 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
// Per-event recording decision for a loaded calendar event, before it is aggregated by meeting.
export type CalendarEventRecordingDecisionForEvent = {
calendarEventId: string;
recordingPreference: string;
realMeetingKey: string;
eventIntent: CalendarEventRecordingIntent;
reason: CalendarEventRecordingDecisionReason;
startsAt: string | null;
};
@@ -0,0 +1,9 @@
export type CalendarEventRecordingDecisionReason =
| 'WORKSPACE_RECORDING_DISABLED'
| 'EVENT_CANCELED'
| 'PREFERENCE_OFF'
| 'PREFERENCE_ON'
| 'AUTO_POLICY_MATCHED'
| 'AUTO_MISSING_CONFERENCE_LINK'
| 'AUTO_EVENT_NOT_UPCOMING'
| 'AUTO_NO_EXTERNAL_PARTICIPANT';
@@ -0,0 +1,12 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingDecisionResult = {
workspaceId: string;
calendarEventId: string;
found: boolean;
recordingPreference: string | null;
realMeetingKey: string | null;
eventIntent: CalendarEventRecordingIntent | null;
reason: CalendarEventRecordingDecisionReason | null;
};
@@ -0,0 +1,7 @@
import { type CalendarEventRecordingDecisionReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-decision-reason.type';
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingDecision = {
eventIntent: CalendarEventRecordingIntent;
reason: CalendarEventRecordingDecisionReason;
};
@@ -0,0 +1,7 @@
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type CalendarEventRecordingIntentForMeeting = {
calendarEventId: string;
realMeetingKey: string;
eventIntent: CalendarEventRecordingIntent;
};
@@ -0,0 +1 @@
export type CalendarEventRecordingIntent = 'ACTIVE' | 'CANCELED';
@@ -0,0 +1,9 @@
// Decoupled from the workspace entity so the policy stays a pure, unit-testable function.
export type CalendarEventRecordingPolicyInput = {
recordingPreference: string;
isCanceled: boolean;
startsAt: string | null;
endsAt: string | null;
conferenceLinkUrl: string | null;
hasExternalParticipant: boolean;
};
@@ -1,56 +0,0 @@
// Per-event recording intent, and the aggregate intent computed in code across every calendar
// event that resolves to the same real meeting. Nothing here is persisted: the provider-dispatch
// PR owns the idempotent bot upsert keyed by realMeetingKey.
export type CalendarEventRecordingIntent = 'ACTIVE' | 'CANCELED';
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';
// 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;
};
export type CalendarEventRecordingDecision = {
eventIntent: CalendarEventRecordingIntent;
reason: CalendarEventRecordingDecisionReason;
};
export type CalendarEventRecordingDecisionResult = {
workspaceId: string;
calendarEventId: string;
found: boolean;
recordingPreference: string | null;
realMeetingKey: string | null;
eventIntent: CalendarEventRecordingIntent | null;
reason: CalendarEventRecordingDecisionReason | null;
};
// 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;
};
// 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 = {
realMeetingKey: string;
startsAt: string | null;
};
@@ -0,0 +1,8 @@
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-intent.type';
export type RealMeetingRecordingAggregate = {
realMeetingKey: string;
providerIntent: CalendarEventRecordingIntent;
calendarEventIds: string[];
activeCalendarEventIds: string[];
};
@@ -0,0 +1,7 @@
// A real meeting occurrence whose source calendar event was deleted. The key + start let the job
// re-check the surviving calendar events for that occurrence and cancel a bot none of them want.
export type RemovedRecordingOccurrence = {
calendarEventId: string;
realMeetingKey: string;
startsAt: string | null;
};
@@ -1,5 +1,5 @@
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';
import { type CalendarEventRecordingPolicyInput } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording.types';
const NOW = new Date('2026-06-05T10:00:00.000Z');
const FUTURE = '2026-06-05T11:00:00.000Z';
@@ -1,17 +1,5 @@
import { type CalendarEventRecordingIntent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording.types';
export type CalendarEventRecordingIntentForMeeting = {
calendarEventId: string;
realMeetingKey: string;
eventIntent: CalendarEventRecordingIntent;
};
export type RealMeetingRecordingAggregate = {
realMeetingKey: string;
providerIntent: CalendarEventRecordingIntent;
calendarEventIds: string[];
activeCalendarEventIds: string[];
};
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
@@ -1,6 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { type CalendarEventRecordingDecisionForEvent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording.types';
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';
@@ -1,10 +1,8 @@
import { isDefined } from 'twenty-shared/utils';
import {
type CalendarEventRecordingDecision,
type CalendarEventRecordingDecisionReason,
type CalendarEventRecordingPolicyInput,
} from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording.types';
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;