Show call recorders as calendar event participants (#23380)

Closes twentyhq/core-team-issues#2729

Call recordings attached to a calendar event are now displayed next to
the human participants, in the timeline event card
(`EventCardCalendarEvent`).

They are rendered as the source app's `AppChip`, rounded so it sits in
the participant avatar group, with the recording status in tooltip



https://github.com/user-attachments/assets/e0c393c8-fd65-4468-8c4f-503dd22c13d5

## Before
No call recorder chip displayed 

TODO: add this in the calendar views (`CalendarEventRow`)
This commit is contained in:
martmull
2026-07-28 23:26:59 +02:00
committed by GitHub
parent f84f242f9d
commit 00e418a039
20 changed files with 308 additions and 29 deletions
@@ -0,0 +1,21 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CallRecordingStatus } from 'src/modules/call-recording/common/enums/call-recording-status.enum';
registerEnumType(CallRecordingStatus, {
name: 'CallRecordingStatus',
description: 'Recording lifecycle status',
});
@ObjectType('TimelineCalendarEventCallRecording')
export class TimelineCalendarEventCallRecordingDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => CallRecordingStatus)
status: CallRecordingStatus;
@Field(() => UUIDScalarType, { nullable: true })
applicationId: string | null;
}
@@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { CalendarChannelVisibility } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { TimelineCalendarEventCallRecordingDTO } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-event-call-recording.dto';
import { TimelineCalendarEventParticipantDTO } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-event-participant.dto';
@ObjectType('LinkMetadata')
@@ -60,6 +61,9 @@ export class TimelineCalendarEventDTO {
@Field(() => [TimelineCalendarEventParticipantDTO])
participants: TimelineCalendarEventParticipantDTO[];
@Field(() => [TimelineCalendarEventCallRecordingDTO])
callRecordings: TimelineCalendarEventCallRecordingDTO[];
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
}
@@ -31,6 +31,7 @@ describe('TimelineCalendarEventService', () => {
let mockUserWorkspaceRepository: { findOne: jest.Mock };
let mockWorkspaceMemberRepository: { findOne: jest.Mock };
let mockFileUrlService: { signFirstFilesFieldFileUrl: jest.Mock };
let mockCallRecordingRepository: { find: jest.Mock };
const mockCalendarEvent: Partial<CalendarEventWorkspaceEntity> = {
id: '1',
@@ -69,6 +70,10 @@ describe('TimelineCalendarEventService', () => {
signFirstFilesFieldFileUrl: jest.fn().mockResolvedValue(null),
};
mockCallRecordingRepository = {
find: jest.fn().mockResolvedValue([]),
};
const mockGlobalWorkspaceOrmManager = {
getRepository: jest
.fn()
@@ -77,6 +82,10 @@ describe('TimelineCalendarEventService', () => {
return Promise.resolve(mockWorkspaceMemberRepository);
}
if (entityName === 'callRecording') {
return Promise.resolve(mockCallRecordingRepository);
}
return Promise.resolve(mockCalendarEventRepository);
}),
executeInWorkspaceContext: jest
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import omit from 'lodash.omit';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { Any, In, type Repository } from 'typeorm';
import { CalendarChannelVisibility } from 'twenty-shared/types';
@@ -16,6 +17,8 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
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 CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type CallRecordingStatus } from 'src/modules/call-recording/common/enums/call-recording-status.enum';
import { type CallRecordingWorkspaceEntity } from 'src/modules/call-recording/standard-objects/call-recording.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
@@ -107,6 +110,50 @@ export class TimelineCalendarEventService {
},
});
const callRecordingRepository =
await this.globalWorkspaceOrmManager.getRepository<CallRecordingWorkspaceEntity>(
workspaceId,
'callRecording',
);
const callRecordings = await callRecordingRepository.find({
where: {
calendarEventId: Any(ids),
},
select: {
id: true,
status: true,
applicationId: true,
calendarEventId: true,
},
});
const callRecordingsByCalendarEventId = callRecordings.reduce<
Map<
string,
{
id: string;
status: CallRecordingStatus;
applicationId: string | null;
}[]
>
>((acc, callRecording) => {
if (!isDefined(callRecording.calendarEventId)) {
return acc;
}
const existing = acc.get(callRecording.calendarEventId) ?? [];
existing.push({
id: callRecording.id,
status: callRecording.status,
applicationId: callRecording.applicationId ?? null,
});
acc.set(callRecording.calendarEventId, existing);
return acc;
}, new Map());
const allCalendarChannelIds = [
...new Set(
events.flatMap((event) =>
@@ -259,6 +306,8 @@ export class TimelineCalendarEventService {
startsAt: event.startsAt as unknown as Date,
endsAt: event.endsAt as unknown as Date,
participants,
callRecordings:
callRecordingsByCalendarEventId.get(event.id) ?? [],
visibility,
location: event.location ?? '',
conferenceSolution: event.conferenceSolution ?? '',