Fix calendar events & messages fetching + fix timeline design (#11840)

Preview : 

<img width="501" alt="Screenshot 2025-05-02 at 16 24 34"
src="https://github.com/user-attachments/assets/0c649df1-0e26-4ddc-8e13-ebd78af7ec09"
/>


Done : 
- Fix getCalendarEventsFromPersonIds and getCalendarEventsFromCompanyId
(include accountOwner check)
- Fix permission check on pre-hook - Pre-hook seems useless, calendar
events are always on METADATA or SHARE_EVERYTHING visibility, else post
hook always has the responsibility of returning the data user can
access. >> To delete or to keep in case other visibility options are
added ?
- Add post hook to secure finOne / findMany calendarEvents resolver
- Update design

To do :
- same on messages (PR to arrive)

closes : https://github.com/twentyhq/twenty/issues/9826
This commit is contained in:
Etienne
2025-05-05 13:12:16 +02:00
committed by GitHub
parent d0d872fdd0
commit 521e75981a
20 changed files with 832 additions and 97 deletions

View File

@ -7,6 +7,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { TIMELINE_CALENDAR_EVENTS_MAX_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
import { TimelineCalendarEventsWithTotal } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-events-with-total.dto';
import { TimelineCalendarEventService } from 'src/engine/core-modules/calendar/timeline-calendar-event.service';
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@ArgsType()
@ -46,13 +47,15 @@ export class TimelineCalendarEventResolver {
async getTimelineCalendarEventsFromPersonId(
@Args()
{ personId, page, pageSize }: GetTimelineCalendarEventsFromPersonIdArgs,
@AuthWorkspaceMemberId() workspaceMemberId: string,
) {
const timelineCalendarEvents =
await this.timelineCalendarEventService.getCalendarEventsFromPersonIds(
[personId],
await this.timelineCalendarEventService.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId: workspaceMemberId,
personIds: [personId],
page,
pageSize,
);
});
return timelineCalendarEvents;
}
@ -61,13 +64,15 @@ export class TimelineCalendarEventResolver {
async getTimelineCalendarEventsFromCompanyId(
@Args()
{ companyId, page, pageSize }: GetTimelineCalendarEventsFromCompanyIdArgs,
@AuthWorkspaceMemberId() workspaceMemberId: string,
) {
const timelineCalendarEvents =
await this.timelineCalendarEventService.getCalendarEventsFromCompanyId(
await this.timelineCalendarEventService.getCalendarEventsFromCompanyId({
currentWorkspaceMemberId: workspaceMemberId,
companyId,
page,
pageSize,
);
});
return timelineCalendarEvents;
}

View File

@ -0,0 +1,176 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { TimelineCalendarEventService } from './timeline-calendar-event.service';
type MockWorkspaceRepository = Partial<
WorkspaceRepository<CalendarEventWorkspaceEntity>
> & {
find: jest.Mock;
findAndCount: jest.Mock;
};
describe('TimelineCalendarEventService', () => {
let service: TimelineCalendarEventService;
let mockCalendarEventRepository: MockWorkspaceRepository;
const mockCalendarEvent: Partial<CalendarEventWorkspaceEntity> = {
id: '1',
title: 'Test Event',
description: 'Test Description',
startsAt: '2024-01-01T00:00:00.000Z',
endsAt: '2024-01-01T01:00:00.000Z',
calendarEventParticipants: [],
calendarChannelEventAssociations: [],
};
beforeEach(async () => {
mockCalendarEventRepository = {
find: jest.fn(),
findAndCount: jest.fn(),
};
const mockTwentyORMManager = {
getRepository: jest.fn().mockResolvedValue(mockCalendarEventRepository),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TimelineCalendarEventService,
{
provide: TwentyORMManager,
useValue: mockTwentyORMManager,
},
],
}).compile();
service = module.get<TimelineCalendarEventService>(
TimelineCalendarEventService,
);
});
it('should return non-obfuscated calendar events if visibility is SHARE_EVERYTHING', async () => {
const currentWorkspaceMemberId = 'current-workspace-member-id';
const personIds = ['person-1'];
mockCalendarEventRepository.find.mockResolvedValue([
{ id: '1', startsAt: new Date() },
]);
mockCalendarEventRepository.findAndCount.mockResolvedValue([
[
{
...mockCalendarEvent,
calendarChannelEventAssociations: [
{
calendarChannel: {
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
connectedAccount: {
accountOwnerId: 'other-workspace-member-id',
},
},
},
],
},
],
1,
]);
const result = await service.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds,
page: 1,
pageSize: 10,
});
expect(result.timelineCalendarEvents[0].title).toBe('Test Event');
expect(result.timelineCalendarEvents[0].description).toBe(
'Test Description',
);
});
it('should return obfuscated calendar events if visibility is METADATA', async () => {
const currentWorkspaceMemberId = 'current-workspace-member-id';
const personIds = ['person-1'];
mockCalendarEventRepository.find.mockResolvedValue([
{ id: '1', startsAt: new Date() },
]);
mockCalendarEventRepository.findAndCount.mockResolvedValue([
[
{
...mockCalendarEvent,
calendarChannelEventAssociations: [
{
calendarChannel: {
visibility: CalendarChannelVisibility.METADATA,
connectedAccount: {
accountOwnerId: 'other-workspace-member-id',
},
},
},
],
},
],
1,
]);
const result = await service.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds,
page: 1,
pageSize: 10,
});
expect(result.timelineCalendarEvents[0].title).toBe(
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
);
expect(result.timelineCalendarEvents[0].description).toBe(
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
);
});
it('should return non-obfuscated calendar events if visibility is METADATA and user is calendar events owner', async () => {
const currentWorkspaceMemberId = 'current-workspace-member-id';
const personIds = ['person-1'];
mockCalendarEventRepository.find.mockResolvedValue([
{ id: '1', startsAt: new Date() },
]);
mockCalendarEventRepository.findAndCount.mockResolvedValue([
[
{
...mockCalendarEvent,
calendarChannelEventAssociations: [
{
calendarChannel: {
visibility: CalendarChannelVisibility.METADATA,
connectedAccount: {
accountOwnerId: 'current-workspace-member-id',
},
},
},
],
},
],
1,
]);
const result = await service.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds,
page: 1,
pageSize: 10,
});
expect(result.timelineCalendarEvents[0].title).toBe('Test Event');
expect(result.timelineCalendarEvents[0].description).toBe(
'Test Description',
);
});
});

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import omit from 'lodash.omit';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { Any } from 'typeorm';
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
@ -15,11 +16,17 @@ export class TimelineCalendarEventService {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
// TODO: Align return type with the entities to avoid mapping
async getCalendarEventsFromPersonIds(
personIds: string[],
async getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds,
page = 1,
pageSize: number = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
): Promise<TimelineCalendarEventsWithTotal> {
pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
}: {
currentWorkspaceMemberId: string;
personIds: string[];
page: number;
pageSize: number;
}): Promise<TimelineCalendarEventsWithTotal> {
const offset = (page - 1) * pageSize;
const calendarEventRepository =
@ -64,7 +71,11 @@ export class TimelineCalendarEventService {
workspaceMember: true,
},
calendarChannelEventAssociations: {
calendarChannel: true,
calendarChannel: {
connectedAccount: {
accountOwner: true,
},
},
},
},
});
@ -103,17 +114,35 @@ export class TimelineCalendarEventService {
handle: participant.handle,
}),
);
const visibility = event.calendarChannelEventAssociations.some(
(association) => association.calendarChannel.visibility === 'METADATA',
)
? CalendarChannelVisibility.METADATA
: CalendarChannelVisibility.SHARE_EVERYTHING;
const isCalendarEventImportedByCurrentWorkspaceMember =
event.calendarChannelEventAssociations.some(
(association) =>
association.calendarChannel.connectedAccount.accountOwnerId ===
currentWorkspaceMemberId,
);
const visibility =
event.calendarChannelEventAssociations.some(
(association) =>
association.calendarChannel.visibility === 'SHARE_EVERYTHING',
) || isCalendarEventImportedByCurrentWorkspaceMember
? CalendarChannelVisibility.SHARE_EVERYTHING
: CalendarChannelVisibility.METADATA;
return {
...omit(event, [
'calendarEventParticipants',
'calendarChannelEventAssociations',
]),
title:
visibility === CalendarChannelVisibility.METADATA
? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
: event.title,
description:
visibility === CalendarChannelVisibility.METADATA
? FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
: event.description,
startsAt: event.startsAt as unknown as Date,
endsAt: event.endsAt as unknown as Date,
participants,
@ -127,11 +156,17 @@ export class TimelineCalendarEventService {
};
}
async getCalendarEventsFromCompanyId(
companyId: string,
async getCalendarEventsFromCompanyId({
currentWorkspaceMemberId,
companyId,
page = 1,
pageSize: number = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
): Promise<TimelineCalendarEventsWithTotal> {
pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
}: {
currentWorkspaceMemberId: string;
companyId: string;
page: number;
pageSize: number;
}): Promise<TimelineCalendarEventsWithTotal> {
const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>(
'person',
@ -155,12 +190,13 @@ export class TimelineCalendarEventService {
const formattedPersonIds = personIds.map(({ id }) => id);
const messageThreads = await this.getCalendarEventsFromPersonIds(
formattedPersonIds,
const calendarEvents = await this.getCalendarEventsFromPersonIds({
currentWorkspaceMemberId,
personIds: formattedPersonIds,
page,
pageSize,
);
});
return messageThreads;
return calendarEvents;
}
}