Harden calendar recording policy handling

This commit is contained in:
ehconitin
2026-06-07 19:36:21 +05:30
parent ad0a89854b
commit 86fd6dc0a5
11 changed files with 304 additions and 47 deletions
@@ -21,6 +21,12 @@ export enum CalendarChannelVisibility {
SHARE_EVERYTHING = 'SHARE_EVERYTHING'
}
export type CalendarEventRecordingPreference = {
__typename?: 'CalendarEventRecordingPreference';
calendarEventId: Scalars['UUID'];
recordingPreference: Scalars['String'];
};
export type ComputeStepOutputSchemaInput = {
/** Step JSON format */
step: Scalars['JSON'];
@@ -144,6 +150,7 @@ export type Mutation = {
stopWorkflowRun: WorkflowRun;
submitFormStep: Scalars['Boolean'];
testHttpRequest: TestHttpRequest;
updateCalendarEventRecordingPreference: CalendarEventRecordingPreference;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
updateWorkflowVersionStep: WorkflowAction;
@@ -225,6 +232,11 @@ export type MutationTestHttpRequestArgs = {
};
export type MutationUpdateCalendarEventRecordingPreferenceArgs = {
input: UpdateCalendarEventRecordingPreferenceInput;
};
export type MutationUpdateWorkflowRunStepArgs = {
input: UpdateWorkflowRunStepInput;
};
@@ -251,6 +263,7 @@ export type ObjectRecordFilterInput = {
export type Query = {
__typename?: 'Query';
canUpdateCalendarEventRecordingPreference: Scalars['Boolean'];
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
getTimelineCalendarEventsFromOpportunityId: TimelineCalendarEventsWithTotal;
getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal;
@@ -263,6 +276,11 @@ export type Query = {
};
export type QueryCanUpdateCalendarEventRecordingPreferenceArgs = {
calendarEventId: Scalars['UUID'];
};
export type QueryGetTimelineCalendarEventsFromCompanyIdArgs = {
companyId: Scalars['UUID'];
page: Scalars['Int'];
@@ -475,6 +493,11 @@ export type UuidFilter = {
neq?: InputMaybe<Scalars['UUID']>;
};
export type UpdateCalendarEventRecordingPreferenceInput = {
calendarEventId: Scalars['UUID'];
recordingPreference: Scalars['String'];
};
export type UpdateWorkflowRunStepInput = {
/** Step to update in JSON format */
step: Scalars['JSON'];
@@ -0,0 +1,126 @@
import { type Repository } from 'typeorm';
import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { CalendarEventRecordingPreferenceService } from 'src/engine/core-modules/calendar/calendar-event-recording-preference.service';
import {
ForbiddenError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
const WORKSPACE_ID = 'workspace-id';
const USER_WORKSPACE_ID = 'user-workspace-id';
const WORKSPACE_MEMBER_ID = 'workspace-member-id';
const CALENDAR_EVENT_ID = 'calendar-event-id';
const AUTH_CONTEXT = {} as UserWorkspaceAuthContext;
const buildCalendarEvent = (
overrides: Partial<CalendarEventWorkspaceEntity> = {},
): CalendarEventWorkspaceEntity => ({
id: CALENDAR_EVENT_ID,
title: 'Customer call',
description: '',
isCanceled: false,
isFullDay: false,
startsAt: '2026-06-05T11:00:00.000Z',
endsAt: '2026-06-05T12:00:00.000Z',
location: '',
conferenceLink: {
primaryLinkLabel: 'Google Meet',
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij',
secondaryLinks: null,
},
externalCreatedAt: '2026-06-01T10:00:00.000Z',
externalUpdatedAt: '2026-06-01T10:00:00.000Z',
deletedAt: null,
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-01T10:00:00.000Z',
iCalUid: 'ical-uid',
conferenceSolution: 'googleMeet',
recordingPreference: 'AUTO',
calendarChannelEventAssociations: [],
calendarEventParticipants: [
{
workspaceMemberId: WORKSPACE_MEMBER_ID,
} as CalendarEventParticipantWorkspaceEntity,
],
...overrides,
});
describe('CalendarEventRecordingPreferenceService', () => {
it('should not update the preference when the user fails custom calendar authorization', async () => {
const calendarEventRepository = {
findOne: jest.fn().mockResolvedValue(
buildCalendarEvent({
calendarEventParticipants: [],
}),
),
update: jest.fn(),
};
const globalWorkspaceOrmManager = {
getRepository: jest.fn().mockResolvedValue(calendarEventRepository),
executeInWorkspaceContext: jest.fn((callback: () => unknown) =>
callback(),
),
};
const service = new CalendarEventRecordingPreferenceService(
globalWorkspaceOrmManager as unknown as GlobalWorkspaceOrmManager,
{} as Repository<CalendarChannelEntity>,
);
await expect(
service.updateCalendarEventRecordingPreference({
workspaceId: WORKSPACE_ID,
userWorkspaceId: USER_WORKSPACE_ID,
workspaceMemberId: WORKSPACE_MEMBER_ID,
calendarEventId: CALENDAR_EVENT_ID,
recordingPreference: 'ON',
authContext: AUTH_CONTEXT,
}),
).rejects.toThrow(ForbiddenError);
expect(calendarEventRepository.update).not.toHaveBeenCalled();
});
it('should throw when the preference update affects no rows', async () => {
const calendarEventRepository = {
findOne: jest.fn().mockResolvedValue(buildCalendarEvent()),
update: jest.fn().mockResolvedValue({ affected: 0 }),
};
const globalWorkspaceOrmManager = {
getRepository: jest.fn().mockResolvedValue(calendarEventRepository),
executeInWorkspaceContext: jest.fn((callback: () => unknown) =>
callback(),
),
};
const service = new CalendarEventRecordingPreferenceService(
globalWorkspaceOrmManager as unknown as GlobalWorkspaceOrmManager,
{} as Repository<CalendarChannelEntity>,
);
await expect(
service.updateCalendarEventRecordingPreference({
workspaceId: WORKSPACE_ID,
userWorkspaceId: USER_WORKSPACE_ID,
workspaceMemberId: WORKSPACE_MEMBER_ID,
calendarEventId: CALENDAR_EVENT_ID,
recordingPreference: 'ON',
authContext: AUTH_CONTEXT,
}),
).rejects.toThrow(NotFoundError);
expect(calendarEventRepository.update).toHaveBeenCalledWith(
CALENDAR_EVENT_ID,
{
recordingPreference: 'ON',
},
);
});
});
@@ -6,7 +6,10 @@ import { In, Repository } from 'typeorm';
import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type CalendarEventRecordingPreferenceDTO } from 'src/engine/core-modules/calendar/dtos/calendar-event-recording-preference.dto';
import { type CalendarEventRecordingPreference } from 'src/engine/core-modules/calendar/types/calendar-event-recording-preference.type';
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ForbiddenError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
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';
@@ -72,12 +75,18 @@ export class CalendarEventRecordingPreferenceService {
await this.globalWorkspaceOrmManager.getRepository<CalendarEventWorkspaceEntity>(
workspaceId,
'calendarEvent',
{ shouldBypassPermissionChecks: true },
);
await calendarEventRepository.update(calendarEventId, {
recordingPreference,
});
const updateResult = await calendarEventRepository.update(
calendarEventId,
{
recordingPreference,
},
);
if ((updateResult.affected ?? 0) === 0) {
throw new NotFoundError('Calendar event not found.');
}
},
authContext,
{ lite: true },
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type CalendarEventRecordingPreference } from 'src/engine/core-modules/calendar/types/calendar-event-recording-preference.type';
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 CalendarEventRecordingPolicyReason } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-policy-reason.type';
@@ -18,7 +19,7 @@ type FoundCalendarEventRecordingPolicyResult = {
workspaceId: string;
calendarEventId: string;
found: true;
recordingPreference: string;
recordingPreference: CalendarEventRecordingPreference;
realMeetingKey: string;
shouldRecord: boolean;
reason: CalendarEventRecordingPolicyReason;
@@ -127,15 +128,19 @@ export class CalendarEventRecordingPolicyService {
const affectedMeetingKeys = new Set<string>();
const occurrenceStartsAtAnchors = new Set<string>();
for (const changedCalendarEvent of changedCalendarEvents) {
affectedMeetingKeys.add(
buildCalendarEventRecordingPolicyResult(changedCalendarEvent, {
const changedCalendarEventPolicyResults = changedCalendarEvents.map(
(calendarEvent) =>
buildCalendarEventRecordingPolicyResult(calendarEvent, {
isRecordingEnabledForWorkspace,
now,
}).realMeetingKey,
);
}),
);
for (const policyResult of changedCalendarEventPolicyResults) {
affectedMeetingKeys.add(policyResult.realMeetingKey);
}
for (const changedCalendarEvent of changedCalendarEvents) {
if (isDefined(changedCalendarEvent.startsAt)) {
occurrenceStartsAtAnchors.add(changedCalendarEvent.startsAt);
}
@@ -163,29 +168,34 @@ export class CalendarEventRecordingPolicyService {
})
: [];
// A changed event with a null start is not returned by the anchor query; keep it so a
// link-less, iCalUid-less occurrence still resolves against itself.
const occurrenceEventsById = new Map<
string,
CalendarEventWorkspaceEntity
>();
const perEventRecordingPolicyResultsByCalendarEventId = new Map(
changedCalendarEventPolicyResults.map((policyResult) => [
policyResult.calendarEventId,
policyResult,
]),
);
for (const calendarEvent of [
...occurrenceSiblingEvents,
...changedCalendarEvents,
]) {
occurrenceEventsById.set(calendarEvent.id, calendarEvent);
}
for (const calendarEvent of occurrenceSiblingEvents) {
if (
perEventRecordingPolicyResultsByCalendarEventId.has(
calendarEvent.id,
)
) {
continue;
}
const perEventRecordingPolicyResults = [
...occurrenceEventsById.values(),
]
.map((calendarEvent) =>
perEventRecordingPolicyResultsByCalendarEventId.set(
calendarEvent.id,
buildCalendarEventRecordingPolicyResult(calendarEvent, {
isRecordingEnabledForWorkspace,
now,
}),
)
);
}
const perEventRecordingPolicyResults = [
...perEventRecordingPolicyResultsByCalendarEventId.values(),
]
.filter((policyResult) =>
affectedMeetingKeys.has(policyResult.realMeetingKey),
)
@@ -1,5 +1,7 @@
import { type CalendarEventRecordingPreference } from 'src/engine/core-modules/calendar/types/calendar-event-recording-preference.type';
export type CalendarEventRecordingPolicyInput = {
recordingPreference: string;
recordingPreference: CalendarEventRecordingPreference;
isCanceled: boolean;
startsAt: string | null;
endsAt: string | null;
@@ -1,8 +1,9 @@
import { type CalendarEventRecordingPreference } from 'src/engine/core-modules/calendar/types/calendar-event-recording-preference.type';
import { type CalendarEventRecordingPolicyResult } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-policy-result.type';
export type CalendarEventRecordingPolicyResultForEvent =
CalendarEventRecordingPolicyResult & {
calendarEventId: string;
recordingPreference: string;
recordingPreference: CalendarEventRecordingPreference;
realMeetingKey: string;
};
@@ -0,0 +1,56 @@
import { buildCalendarEventRecordingPolicyResult } from 'src/modules/calendar/calendar-event-recording-manager/utils/build-calendar-event-recording-policy-result.util';
import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
const NOW = new Date('2026-06-05T10:00:00.000Z');
const buildCalendarEvent = (
overrides: Partial<CalendarEventWorkspaceEntity> = {},
): CalendarEventWorkspaceEntity => ({
id: 'calendar-event-id',
title: 'Customer call',
description: '',
isCanceled: false,
isFullDay: false,
startsAt: '2026-06-05T11:00:00.000Z',
endsAt: '2026-06-05T12:00:00.000Z',
location: '',
conferenceLink: {
primaryLinkLabel: 'Google Meet',
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij',
secondaryLinks: null,
},
externalCreatedAt: '2026-06-01T10:00:00.000Z',
externalUpdatedAt: '2026-06-01T10:00:00.000Z',
deletedAt: null,
createdAt: '2026-06-01T10:00:00.000Z',
updatedAt: '2026-06-01T10:00:00.000Z',
iCalUid: 'ical-uid',
conferenceSolution: 'googleMeet',
recordingPreference: 'AUTO',
calendarChannelEventAssociations: [],
calendarEventParticipants: [
{
workspaceMemberId: null,
} as CalendarEventParticipantWorkspaceEntity,
],
...overrides,
});
describe('buildCalendarEventRecordingPolicyResult', () => {
it('should normalize unknown recording preferences to AUTO before resolving policy', () => {
const result = buildCalendarEventRecordingPolicyResult(
buildCalendarEvent({
recordingPreference: 'SOMETHING_ELSE',
}),
{
isRecordingEnabledForWorkspace: true,
now: NOW,
},
);
expect(result.recordingPreference).toBe('AUTO');
expect(result.shouldRecord).toBe(true);
expect(result.reason).toBe('AUTO_POLICY_MATCHED');
});
});
@@ -59,6 +59,19 @@ describe('computeRealMeetingKey', () => {
).toBe('ical:recurring-uid@google.com:2026-06-05T11:00:00.000Z');
});
it('should ignore malformed conference link provider data', () => {
expect(
computeRealMeetingKey({
calendarEventId: 'event-1',
conferenceLinkUrl: {
primaryLinkUrl: 'not-a-string',
} as unknown as string,
iCalUid: 'recurring-uid@google.com',
startsAt: '2026-06-05T11:00:00.000Z',
}),
).toBe('ical:recurring-uid@google.com:2026-06-05T11:00:00.000Z');
});
it('should separate recurring occurrences by start time', () => {
const firstOccurrence = computeRealMeetingKey({
calendarEventId: 'event-1',
@@ -65,17 +65,6 @@ describe('resolveCalendarEventRecordingPolicyResult', () => {
});
});
it('should treat an unknown preference as AUTO', () => {
expect(
resolvePolicyResult(
buildPolicyInput({ recordingPreference: 'SOMETHING_ELSE' }),
),
).toEqual({
shouldRecord: true,
reason: 'AUTO_POLICY_MATCHED',
});
});
it('should keep an in-progress meeting eligible via its end time', () => {
expect(
resolvePolicyResult(
@@ -1,5 +1,9 @@
import { isDefined } from 'twenty-shared/utils';
import {
CALENDAR_EVENT_RECORDING_PREFERENCES,
type CalendarEventRecordingPreference,
} from 'src/engine/core-modules/calendar/types/calendar-event-recording-preference.type';
import { type CalendarEventRecordingPolicyResultForEvent } from 'src/modules/calendar/calendar-event-recording-manager/types/calendar-event-recording-policy-result-for-event.type';
import { computeRealMeetingKey } from 'src/modules/calendar/calendar-event-recording-manager/utils/compute-real-meeting-key.util';
import { resolveCalendarEventRecordingPolicyResult } from 'src/modules/calendar/calendar-event-recording-manager/utils/resolve-calendar-event-recording-policy-result.util';
@@ -30,10 +34,13 @@ export const buildCalendarEventRecordingPolicyResult = (
const hasExternalParticipant = (
calendarEvent.calendarEventParticipants ?? []
).some((participant) => !isDefined(participant.workspaceMemberId));
const recordingPreference = normalizeCalendarEventRecordingPreference(
calendarEvent.recordingPreference,
);
const policyResult = resolveCalendarEventRecordingPolicyResult({
input: {
recordingPreference: calendarEvent.recordingPreference,
recordingPreference,
isCanceled: calendarEvent.isCanceled,
startsAt: calendarEvent.startsAt,
endsAt: calendarEvent.endsAt,
@@ -46,8 +53,23 @@ export const buildCalendarEventRecordingPolicyResult = (
return {
calendarEventId: calendarEvent.id,
recordingPreference: calendarEvent.recordingPreference,
recordingPreference,
realMeetingKey,
...policyResult,
};
};
const normalizeCalendarEventRecordingPreference = (
recordingPreference: string,
): CalendarEventRecordingPreference =>
isCalendarEventRecordingPreference(recordingPreference)
? recordingPreference
: 'AUTO';
const isCalendarEventRecordingPreference = (
recordingPreference: string,
): recordingPreference is CalendarEventRecordingPreference =>
CALENDAR_EVENT_RECORDING_PREFERENCES.some(
(calendarEventRecordingPreference) =>
calendarEventRecordingPreference === recordingPreference,
);
@@ -1,3 +1,4 @@
import { isNonEmptyString, isString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type ComputeRealMeetingKeyInput = {
@@ -30,12 +31,17 @@ export const computeRealMeetingKey = ({
const normalizeConferenceLink = (
conferenceLinkUrl: string | null,
): string | null => {
if (!isDefined(conferenceLinkUrl) || conferenceLinkUrl.trim() === '') {
if (!isString(conferenceLinkUrl)) {
return null;
}
const withoutProtocol = conferenceLinkUrl
.trim()
const trimmedConferenceLinkUrl = conferenceLinkUrl.trim();
if (!isNonEmptyString(trimmedConferenceLinkUrl)) {
return null;
}
const withoutProtocol = trimmedConferenceLinkUrl
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '');