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

@ -0,0 +1,36 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconLock } from 'twenty-ui/display';
import { Card, CardContent } from 'twenty-ui/layout';
const StyledVisibilityCard = styled(Card)`
border-color: ${({ theme }) => theme.border.color.light};
color: ${({ theme }) => theme.font.color.light};
flex: 1;
transition: color ${({ theme }) => theme.animation.duration.normal} ease;
width: 100%;
`;
const StyledVisibilityCardContent = styled(CardContent)`
align-items: center;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0, 1)};
height: ${({ theme }) => theme.spacing(6)};
background-color: ${({ theme }) => theme.background.transparent.lighter};
`;
export const CalendarEventNotSharedContent = () => {
const theme = useTheme();
return (
<StyledVisibilityCard>
<StyledVisibilityCardContent>
<IconLock size={theme.icon.size.sm} />
Not shared
</StyledVisibilityCardContent>
</StyledVisibilityCard>
);
};

View File

@ -0,0 +1,68 @@
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { isTimelineCalendarEventParticipant } from '@/activities/calendar/types/guards/IsTimelineCalendarEventParticipant';
import { isDefined } from 'twenty-shared/utils';
import { Avatar, AvatarGroup } from 'twenty-ui/display';
import { TimelineCalendarEventParticipant } from '~/generated-metadata/graphql';
type CalendarEventParticipantsAvatarGroupProps = {
participants: CalendarEventParticipant[] | TimelineCalendarEventParticipant[];
};
export const CalendarEventParticipantsAvatarGroup = ({
participants,
}: CalendarEventParticipantsAvatarGroupProps) => {
const timelineParticipants: TimelineCalendarEventParticipant[] =
participants.map((participant) => {
if (isTimelineCalendarEventParticipant(participant)) {
return participant;
} else {
return {
personId: participant.person?.id ?? null,
workspaceMemberId: participant.workspaceMember?.id ?? null,
firstName:
participant.person?.name?.firstName ||
participant.workspaceMember?.name.firstName ||
'',
lastName:
participant.person?.name?.lastName ||
participant.workspaceMember?.name.lastName ||
'',
displayName:
participant.person?.name?.firstName ||
participant.person?.name?.lastName ||
participant.workspaceMember?.name.firstName ||
participant.workspaceMember?.name.lastName ||
participant.displayName ||
participant.handle ||
'',
avatarUrl:
participant.person?.avatarUrl ||
participant.workspaceMember?.avatarUrl ||
'',
handle: participant.handle,
};
}
});
return (
<AvatarGroup
avatars={timelineParticipants.map((participant) => (
<Avatar
key={[participant.workspaceMemberId, participant.displayName]
.filter(isDefined)
.join('-')}
avatarUrl={participant.avatarUrl}
placeholder={
participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: participant.displayName
}
placeholderColorSeed={
participant.workspaceMemberId || participant.personId
}
type="rounded"
/>
))}
/>
);
};

View File

@ -5,24 +5,19 @@ import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor';
import { CalendarEventNotSharedContent } from '@/activities/calendar/components/CalendarEventNotSharedContent';
import { CalendarEventParticipantsAvatarGroup } from '@/activities/calendar/components/CalendarEventParticipantsAvatarGroup';
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useOpenCalendarEventInCommandMenu } from '@/command-menu/hooks/useOpenCalendarEventInCommandMenu';
import { IconArrowRight } from 'twenty-ui/display';
import {
CalendarChannelVisibility,
TimelineCalendarEvent,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import {
Avatar,
AvatarGroup,
IconArrowRight,
IconLock,
} from 'twenty-ui/display';
import { Card, CardContent } from 'twenty-ui/layout';
type CalendarEventRowProps = {
calendarEvent: TimelineCalendarEvent;
@ -87,25 +82,6 @@ const StyledTitle = styled.div<{ active: boolean; canceled: boolean }>`
`}
`;
const StyledVisibilityCard = styled(Card)<{ active: boolean }>`
color: ${({ active, theme }) =>
active ? theme.font.color.primary : theme.font.color.light};
border-color: ${({ theme }) => theme.border.color.light};
flex: 1 0 auto;
transition: color ${({ theme }) => theme.animation.duration.normal} ease;
`;
const StyledVisibilityCardContent = styled(CardContent)`
align-items: center;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(0, 1)};
height: ${({ theme }) => theme.spacing(6)};
background-color: ${({ theme }) => theme.background.transparent.lighter};
`;
export const CalendarEventRow = ({
calendarEvent,
className,
@ -159,33 +135,12 @@ export const CalendarEventRow = ({
{calendarEvent.title}
</StyledTitle>
) : (
<StyledVisibilityCard active={!hasEnded}>
<StyledVisibilityCardContent>
<IconLock size={theme.icon.size.sm} />
Not shared
</StyledVisibilityCardContent>
</StyledVisibilityCard>
<CalendarEventNotSharedContent />
)}
</StyledLabels>
{!!calendarEvent.participants?.length && (
<AvatarGroup
avatars={calendarEvent.participants.map((participant) => (
<Avatar
key={[participant.workspaceMemberId, participant.displayName]
.filter(isDefined)
.join('-')}
avatarUrl={participant.avatarUrl}
placeholder={
participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: participant.displayName
}
placeholderColorSeed={
participant.workspaceMemberId || participant.personId
}
type="rounded"
/>
))}
<CalendarEventParticipantsAvatarGroup
participants={calendarEvent.participants}
/>
)}
{displayCurrentEventCursor && (

View File

@ -0,0 +1,8 @@
import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant';
import { TimelineCalendarEventParticipant } from '~/generated-metadata/graphql';
export const isTimelineCalendarEventParticipant = (
participant: CalendarEventParticipant | TimelineCalendarEventParticipant,
): participant is TimelineCalendarEventParticipant => {
return 'avatarUrl' in participant;
};

View File

@ -1,19 +1,22 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { CalendarEventNotSharedContent } from '@/activities/calendar/components/CalendarEventNotSharedContent';
import { CalendarEventParticipantsAvatarGroup } from '@/activities/calendar/components/CalendarEventParticipantsAvatarGroup';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
formatToHumanReadableDay,
formatToHumanReadableMonth,
formatToHumanReadableTime,
} from '~/utils/format/formatDate';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isDefined } from 'twenty-shared/utils';
const StyledEventCardCalendarEventContainer = styled.div`
cursor: pointer;
@ -29,12 +32,14 @@ const StyledCalendarEventContent = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
overflow: hidden;
width: 100%;
`;
const StyledCalendarEventTop = styled.div`
align-items: center;
align-self: stretch;
display: flex;
width: 100%;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: space-between;
`;
@ -100,6 +105,12 @@ export const EventCardCalendarEvent = ({
title: true,
startsAt: true,
endsAt: true,
calendarEventParticipants: {
person: true,
workspaceMember: true,
handle: true,
displayName: true,
},
},
onCompleted: (data) => {
upsertRecords([data]);
@ -114,7 +125,7 @@ export const EventCardCalendarEvent = ({
);
if (shouldHideMessageContent) {
return <div>Calendar event not shared</div>;
return <CalendarEventNotSharedContent />;
}
const shouldHandleNotFound = error.graphQLErrors.some(
@ -160,10 +171,21 @@ export const EventCardCalendarEvent = ({
</StyledCalendarEventDateCard>
<StyledCalendarEventContent>
<StyledCalendarEventTop>
<StyledCalendarEventTitle>
{calendarEvent.title}
</StyledCalendarEventTitle>
{calendarEvent.title ===
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED ? (
<CalendarEventNotSharedContent />
) : (
<StyledCalendarEventTitle>
{calendarEvent.title}
</StyledCalendarEventTitle>
)}
{!!calendarEvent.calendarEventParticipants?.length && (
<CalendarEventParticipantsAvatarGroup
participants={calendarEvent.calendarEventParticipants}
/>
)}
</StyledCalendarEventTop>
<StyledCalendarEventBody>
{startsAtHour} {endsAtHour && <> {endsAtHour}</>}
</StyledCalendarEventBody>

View File

@ -183,7 +183,9 @@ export abstract class GraphqlQueryBaseResolverService<
resultWithGettersArray,
);
return resultWithGetters;
return Array.isArray(resultWithGetters)
? resultWithGettersArray
: resultWithGettersArray[0];
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error, options);
}

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;
}
}

View File

@ -0,0 +1,35 @@
import { WorkspaceQueryPostHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ApplyCalendarEventsVisibilityRestrictionsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service';
import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
@WorkspaceQueryHook({
key: `calendarEvent.findMany`,
type: WorkspaceQueryHookType.PostHook,
})
export class CalendarEventFindManyPostQueryHook
implements WorkspaceQueryPostHookInstance
{
constructor(
private readonly applyCalendarEventsVisibilityRestrictionsService: ApplyCalendarEventsVisibilityRestrictionsService,
) {}
async execute(
authContext: AuthContext,
_objectName: string,
payload: CalendarEventWorkspaceEntity[],
): Promise<void> {
if (!authContext.workspaceMemberId) {
throw new UserInputError('Workspace member id is required');
}
await this.applyCalendarEventsVisibilityRestrictionsService.applyCalendarEventsVisibilityRestrictions(
authContext.workspaceMemberId,
payload,
);
}
}

View File

@ -6,7 +6,7 @@ import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CanAccessCalendarEventService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service';
import { CanAccessCalendarEventsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
@WorkspaceQueryHook({
@ -18,7 +18,7 @@ export class CalendarEventFindManyPreQueryHook
{
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly canAccessCalendarEventService: CanAccessCalendarEventService,
private readonly canAccessCalendarEventsService: CanAccessCalendarEventsService,
) {}
async execute(
@ -51,7 +51,7 @@ export class CalendarEventFindManyPreQueryHook
throw new NotFoundException();
}
await this.canAccessCalendarEventService.canAccessCalendarEvent(
await this.canAccessCalendarEventsService.canAccessCalendarEvents(
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,

View File

@ -0,0 +1,35 @@
import { WorkspaceQueryPostHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ApplyCalendarEventsVisibilityRestrictionsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service';
import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
@WorkspaceQueryHook({
key: `calendarEvent.findOne`,
type: WorkspaceQueryHookType.PostHook,
})
export class CalendarEventFindOnePostQueryHook
implements WorkspaceQueryPostHookInstance
{
constructor(
private readonly applyCalendarEventsVisibilityRestrictionsService: ApplyCalendarEventsVisibilityRestrictionsService,
) {}
async execute(
authContext: AuthContext,
_objectName: string,
payload: CalendarEventWorkspaceEntity[],
): Promise<void> {
if (!authContext.workspaceMemberId) {
throw new UserInputError('Workspace member id is required');
}
await this.applyCalendarEventsVisibilityRestrictionsService.applyCalendarEventsVisibilityRestrictions(
authContext.workspaceMemberId,
payload,
);
}
}

View File

@ -6,7 +6,7 @@ import { FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-b
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CanAccessCalendarEventService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service';
import { CanAccessCalendarEventsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
@WorkspaceQueryHook({
@ -18,7 +18,7 @@ export class CalendarEventFindOnePreQueryHook
{
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly canAccessCalendarEventService: CanAccessCalendarEventService,
private readonly canAccessCalendarEventsService: CanAccessCalendarEventsService,
) {}
async execute(
@ -51,7 +51,7 @@ export class CalendarEventFindOnePreQueryHook
throw new NotFoundException();
}
await this.canAccessCalendarEventService.canAccessCalendarEvent(
await this.canAccessCalendarEventsService.canAccessCalendarEvents(
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,

View File

@ -0,0 +1,249 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
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 { ApplyCalendarEventsVisibilityRestrictionsService } from './apply-calendar-events-visibility-restrictions.service';
const createMockCalendarEvent = (
id: string,
title: string,
description: string,
): CalendarEventWorkspaceEntity => ({
id,
title,
description,
isCanceled: false,
isFullDay: false,
startsAt: '2024-03-20T10:00:00Z',
endsAt: '2024-03-20T11:00:00Z',
location: '',
conferenceLink: {
primaryLinkLabel: '',
primaryLinkUrl: '',
secondaryLinks: null,
},
externalCreatedAt: '2024-03-20T09:00:00Z',
externalUpdatedAt: '2024-03-20T09:00:00Z',
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
iCalUID: '',
conferenceSolution: '',
calendarChannelEventAssociations: [],
calendarEventParticipants: [],
});
describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
let service: ApplyCalendarEventsVisibilityRestrictionsService;
const mockCalendarEventAssociationRepository = {
find: jest.fn(),
};
const mockConnectedAccountRepository = {
find: jest.fn(),
};
const mockTwentyORMManager = {
getRepository: jest.fn().mockImplementation((name) => {
if (name === 'calendarChannelEventAssociation') {
return mockCalendarEventAssociationRepository;
}
if (name === 'connectedAccount') {
return mockConnectedAccountRepository;
}
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplyCalendarEventsVisibilityRestrictionsService,
{
provide: TwentyORMManager,
useValue: mockTwentyORMManager,
},
],
}).compile();
service = module.get<ApplyCalendarEventsVisibilityRestrictionsService>(
ApplyCalendarEventsVisibilityRestrictionsService,
);
// Clear all mocks before each test
jest.clearAllMocks();
});
it('should return calendar event without obfuscated title and description if the visibility is SHARE_EVERYTHING', async () => {
const calendarEvents = [
createMockCalendarEvent('1', 'Test Event', 'Test Description'),
];
mockCalendarEventAssociationRepository.find.mockResolvedValue([
{
calendarEventId: '1',
calendarChannel: {
id: '1',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
},
},
]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
'workspace-member-id',
calendarEvents,
);
expect(result).toEqual(calendarEvents);
expect(
result.every(
(item) =>
item.title !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED &&
item.description !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
),
).toBe(true);
expect(mockConnectedAccountRepository.find).not.toHaveBeenCalled();
});
it('should return calendar event with obfuscated title and description if the visibility is METADATA', async () => {
const calendarEvents = [
createMockCalendarEvent('1', 'Test Event', 'Test Description'),
];
mockCalendarEventAssociationRepository.find.mockResolvedValue([
{
calendarEventId: '1',
calendarChannel: {
id: '1',
visibility: CalendarChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
'workspace-member-id',
calendarEvents,
);
expect(result).toEqual([
{
...calendarEvents[0],
title: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
description: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
]);
});
it('should return calendar event without obfuscated title and description if the workspace member is the owner of the calendar event', async () => {
const calendarEvents = [
createMockCalendarEvent('1', 'Test Event', 'Test Description'),
];
mockCalendarEventAssociationRepository.find.mockResolvedValue([
{
calendarEventId: '1',
calendarChannel: {
id: '1',
visibility: CalendarChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([{ id: '1' }]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
'workspace-member-id',
calendarEvents,
);
expect(result).toEqual(calendarEvents);
expect(
result.every(
(item) =>
item.title !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED &&
item.description !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
),
).toBe(true);
});
it('should not return calendar event if visibility is not SHARE_EVERYTHING or METADATA and the workspace member is not the owner of the calendar event', async () => {
const calendarEvents = [
createMockCalendarEvent('1', 'Test Event', 'Test Description'),
];
mockCalendarEventAssociationRepository.find.mockResolvedValue([
{
calendarEventId: '1',
calendarChannel: {
id: '1',
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
'workspace-member-id',
calendarEvents,
);
expect(result).toEqual([]);
});
it('should return all calendar events with the right visibility', async () => {
const calendarEvents = [
createMockCalendarEvent('1', 'Event 1', 'Description 1'),
createMockCalendarEvent('2', 'Event 2', 'Description 2'),
createMockCalendarEvent('3', 'Event 3', 'Description 3'),
];
mockCalendarEventAssociationRepository.find.mockResolvedValue([
{
calendarEventId: '1',
calendarChannel: {
id: '1',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
},
},
{
calendarEventId: '2',
calendarChannel: {
id: '2',
visibility: CalendarChannelVisibility.METADATA,
},
},
{
calendarEventId: '3',
calendarChannel: {
id: '3',
visibility: CalendarChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find
.mockResolvedValueOnce([]) // request for calendar event 3
.mockResolvedValueOnce([{ id: '1' }]); // request for calendar event 2
const result = await service.applyCalendarEventsVisibilityRestrictions(
'workspace-member-id',
calendarEvents,
);
expect(result).toEqual([
calendarEvents[0],
calendarEvents[1],
{
...calendarEvents[2],
title: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
description: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
]);
});
});

View File

@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import groupBy from 'lodash.groupby';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { In } from 'typeorm';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
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 { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
@Injectable()
export class ApplyCalendarEventsVisibilityRestrictionsService {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
public async applyCalendarEventsVisibilityRestrictions(
workspaceMemberId: string,
calendarEvents: CalendarEventWorkspaceEntity[],
) {
const calendarChannelEventAssociationRepository =
await this.twentyORMManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
'calendarChannelEventAssociation',
);
const calendarChannelCalendarEventsAssociations =
await calendarChannelEventAssociationRepository.find({
where: {
calendarEventId: In(calendarEvents.map((event) => event.id)),
},
relations: ['calendarChannel', 'calendarChannel.connectedAccount'],
});
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
for (let i = calendarEvents.length - 1; i >= 0; i--) {
const calendarChannelCalendarEventAssociations =
calendarChannelCalendarEventsAssociations.filter(
(association) => association.calendarEventId === calendarEvents[i].id,
);
const calendarChannels = calendarChannelCalendarEventAssociations.map(
(association) => association.calendarChannel,
);
const calendarChannelsGroupByVisibility = groupBy(
calendarChannels,
(channel) => channel.visibility,
);
if (
calendarChannelsGroupByVisibility[
CalendarChannelVisibility.SHARE_EVERYTHING
]
) {
continue;
}
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
calendarChannels: {
id: In(calendarChannels.map((channel) => channel.id)),
},
accountOwnerId: workspaceMemberId,
},
});
if (connectedAccounts.length > 0) {
continue;
}
if (
calendarChannelsGroupByVisibility[CalendarChannelVisibility.METADATA]
) {
calendarEvents[i].title =
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
calendarEvents[i].description =
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
continue;
}
calendarEvents.splice(i, 1);
}
return calendarEvents;
}
}

View File

@ -1,8 +1,9 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import groupBy from 'lodash.groupby';
import { Any } from 'typeorm';
import { In } from 'typeorm';
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
@ -12,14 +13,14 @@ import { WorkspaceMemberRepository } from 'src/modules/workspace-member/reposito
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class CanAccessCalendarEventService {
export class CanAccessCalendarEventsService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberService: WorkspaceMemberRepository,
) {}
public async canAccessCalendarEvent(
public async canAccessCalendarEvents(
userId: string,
workspaceId: string,
calendarChannelCalendarEventAssociations: CalendarChannelEventAssociationWorkspaceEntity[],
@ -36,7 +37,8 @@ export class CanAccessCalendarEventService {
if (
calendarChannelsGroupByVisibility[
CalendarChannelVisibility.SHARE_EVERYTHING
]
] ||
calendarChannelsGroupByVisibility[CalendarChannelVisibility.METADATA]
) {
return;
}
@ -52,7 +54,9 @@ export class CanAccessCalendarEventService {
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
calendarChannels: Any(calendarChannels.map((channel) => channel.id)),
calendarChannels: {
id: In(calendarChannels.map((channel) => channel.id)),
},
accountOwnerId: currentWorkspaceMember.id,
},
});
@ -61,6 +65,6 @@ export class CanAccessCalendarEventService {
return;
}
throw new ForbiddenException();
throw new ForbiddenError('Calendar events not shared');
}
}

View File

@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { CalendarEventFindManyPostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook';
import { CalendarEventFindManyPreQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook';
import { CalendarEventFindOnePostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook';
import { CalendarEventFindOnePreQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook';
import { CanAccessCalendarEventService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service';
import { ApplyCalendarEventsVisibilityRestrictionsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service';
import { CanAccessCalendarEventsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
@ -11,9 +14,12 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
],
providers: [
CanAccessCalendarEventService,
CanAccessCalendarEventsService,
ApplyCalendarEventsVisibilityRestrictionsService,
CalendarEventFindOnePreQueryHook,
CalendarEventFindManyPreQueryHook,
CalendarEventFindOnePostQueryHook,
CalendarEventFindManyPostQueryHook,
],
})
export class CalendarQueryHookModule {}

View File

@ -0,0 +1,2 @@
export const FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED =
'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';

View File

@ -8,6 +8,7 @@
*/
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions';
export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl';

View File

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
export type AvatarGroupProps = {
avatars: ReactNode[];
@ -12,6 +12,10 @@ const StyledContainer = styled.div`
const StyledItemContainer = styled.div`
margin-right: -3px;
&:last-child {
margin-right: 0;
}
`;
const MAX_AVATARS_NB = 4;