(
-
- ))}
+
)}
{displayCurrentEventCursor && (
diff --git a/packages/twenty-front/src/modules/activities/calendar/types/guards/IsTimelineCalendarEventParticipant.ts b/packages/twenty-front/src/modules/activities/calendar/types/guards/IsTimelineCalendarEventParticipant.ts
new file mode 100644
index 000000000..7510afde7
--- /dev/null
+++ b/packages/twenty-front/src/modules/activities/calendar/types/guards/IsTimelineCalendarEventParticipant.ts
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx
index 259ab3c60..629371e40 100644
--- a/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx
+++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx
@@ -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 Calendar event not shared
;
+ return ;
}
const shouldHandleNotFound = error.graphQLErrors.some(
@@ -160,10 +171,21 @@ export const EventCardCalendarEvent = ({
-
- {calendarEvent.title}
-
+ {calendarEvent.title ===
+ FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED ? (
+
+ ) : (
+
+ {calendarEvent.title}
+
+ )}
+ {!!calendarEvent.calendarEventParticipants?.length && (
+
+ )}
+
{startsAtHour} {endsAtHour && <>→ {endsAtHour}>}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts
index 285bba5b2..740558d7a 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts
@@ -183,7 +183,9 @@ export abstract class GraphqlQueryBaseResolverService<
resultWithGettersArray,
);
- return resultWithGetters;
+ return Array.isArray(resultWithGetters)
+ ? resultWithGettersArray
+ : resultWithGettersArray[0];
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error, options);
}
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts
index c6656e0ef..51c9ed7a4 100644
--- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.resolver.ts
@@ -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;
}
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts
new file mode 100644
index 000000000..19128e77b
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts
@@ -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
+> & {
+ find: jest.Mock;
+ findAndCount: jest.Mock;
+};
+
+describe('TimelineCalendarEventService', () => {
+ let service: TimelineCalendarEventService;
+ let mockCalendarEventRepository: MockWorkspaceRepository;
+
+ const mockCalendarEvent: Partial = {
+ 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,
+ );
+ });
+
+ 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',
+ );
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
index c26c03959..a3ba247ee 100644
--- a/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.ts
@@ -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 {
+ pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
+ }: {
+ currentWorkspaceMemberId: string;
+ personIds: string[];
+ page: number;
+ pageSize: number;
+ }): Promise {
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 {
+ pageSize = TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE,
+ }: {
+ currentWorkspaceMemberId: string;
+ companyId: string;
+ page: number;
+ pageSize: number;
+ }): Promise {
const personRepository =
await this.twentyORMManager.getRepository(
'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;
}
}
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook.ts
new file mode 100644
index 000000000..5cbee2789
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook.ts
@@ -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 {
+ if (!authContext.workspaceMemberId) {
+ throw new UserInputError('Workspace member id is required');
+ }
+
+ await this.applyCalendarEventsVisibilityRestrictionsService.applyCalendarEventsVisibilityRestrictions(
+ authContext.workspaceMemberId,
+ payload,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook.ts
index 3f623184a..e4b68f726 100644
--- a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook.ts
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.pre-query.hook.ts
@@ -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,
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook.ts
new file mode 100644
index 000000000..efbb20712
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook.ts
@@ -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 {
+ if (!authContext.workspaceMemberId) {
+ throw new UserInputError('Workspace member id is required');
+ }
+
+ await this.applyCalendarEventsVisibilityRestrictionsService.applyCalendarEventsVisibilityRestrictions(
+ authContext.workspaceMemberId,
+ payload,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook.ts
index dc2a92dd7..359721b9b 100644
--- a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook.ts
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.pre-query-hook.ts
@@ -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,
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.spec.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.spec.ts
new file mode 100644
index 000000000..3f9055dd1
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.spec.ts
@@ -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,
+ );
+
+ // 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,
+ },
+ ]);
+ });
+});
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.ts
new file mode 100644
index 000000000..4ff0e03aa
--- /dev/null
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.ts
@@ -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(
+ 'calendarChannelEventAssociation',
+ );
+
+ const calendarChannelCalendarEventsAssociations =
+ await calendarChannelEventAssociationRepository.find({
+ where: {
+ calendarEventId: In(calendarEvents.map((event) => event.id)),
+ },
+ relations: ['calendarChannel', 'calendarChannel.connectedAccount'],
+ });
+
+ const connectedAccountRepository =
+ await this.twentyORMManager.getRepository(
+ '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;
+ }
+}
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service.ts
similarity index 81%
rename from packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service.ts
rename to packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service.ts
index 2e8810d1f..e58e3118e 100644
--- a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-event.service.ts
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/can-access-calendar-events.service.ts
@@ -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');
}
}
diff --git a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-query-hook.module.ts b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-query-hook.module.ts
index 2c5a26b18..1b21ef65d 100644
--- a/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-query-hook.module.ts
+++ b/packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-query-hook.module.ts
@@ -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 {}
diff --git a/packages/twenty-shared/src/constants/FieldRestrictedAdditionalPermissionsRequired.ts b/packages/twenty-shared/src/constants/FieldRestrictedAdditionalPermissionsRequired.ts
new file mode 100644
index 000000000..ad1576b4f
--- /dev/null
+++ b/packages/twenty-shared/src/constants/FieldRestrictedAdditionalPermissionsRequired.ts
@@ -0,0 +1,2 @@
+export const FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED =
+ 'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED';
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index 50a06be40..18ded460f 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -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';
diff --git a/packages/twenty-ui/src/display/avatar/components/AvatarGroup.tsx b/packages/twenty-ui/src/display/avatar/components/AvatarGroup.tsx
index 6bf437b87..92ed640d1 100644
--- a/packages/twenty-ui/src/display/avatar/components/AvatarGroup.tsx
+++ b/packages/twenty-ui/src/display/avatar/components/AvatarGroup.tsx
@@ -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;