add upgrade command
This commit is contained in:
+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',
|
||||
},
|
||||
],
|
||||
|
||||
+6
-1
@@ -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 {}
|
||||
|
||||
+12
-12
@@ -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'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -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',
|
||||
|
||||
+25
-4
@@ -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,
|
||||
|
||||
+10
-2
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+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],
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-8
@@ -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';
|
||||
|
||||
|
||||
+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;
|
||||
};
|
||||
-56
@@ -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;
|
||||
};
|
||||
+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;
|
||||
};
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+2
-14
@@ -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
-1
@@ -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';
|
||||
|
||||
+3
-5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user