From 521e75981a900d4e1a720220e5c995c435f53039 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Mon, 5 May 2025 13:12:16 +0200 Subject: [PATCH] Fix calendar events & messages fetching + fix timeline design (#11840) Preview : Screenshot 2025-05-02 at 16 24 34 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 --- .../CalendarEventNotSharedContent.tsx | 36 +++ .../CalendarEventParticipantsAvatarGroup.tsx | 68 +++++ .../calendar/components/CalendarEventRow.tsx | 57 +--- .../IsTimelineCalendarEventParticipant.ts | 8 + .../components/EventCardCalendarEvent.tsx | 34 ++- .../interfaces/base-resolver-service.ts | 4 +- .../timeline-calendar-event.resolver.ts | 15 +- .../timeline-calendar-event.service.spec.ts | 176 +++++++++++++ .../timeline-calendar-event.service.ts | 72 +++-- ...alendar-event-find-many.post-query.hook.ts | 35 +++ ...calendar-event-find-many.pre-query.hook.ts | 6 +- ...calendar-event-find-one.post-query.hook.ts | 35 +++ .../calendar-event-find-one.pre-query-hook.ts | 6 +- ...ts-visibility-restrictions.service.spec.ts | 249 ++++++++++++++++++ ...-events-visibility-restrictions.service.ts | 91 +++++++ ... => can-access-calendar-events.service.ts} | 18 +- .../query-hooks/calendar-query-hook.module.ts | 10 +- ...RestrictedAdditionalPermissionsRequired.ts | 2 + packages/twenty-shared/src/constants/index.ts | 1 + .../display/avatar/components/AvatarGroup.tsx | 6 +- 20 files changed, 832 insertions(+), 97 deletions(-) create mode 100644 packages/twenty-front/src/modules/activities/calendar/components/CalendarEventNotSharedContent.tsx create mode 100644 packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsAvatarGroup.tsx create mode 100644 packages/twenty-front/src/modules/activities/calendar/types/guards/IsTimelineCalendarEventParticipant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/calendar/timeline-calendar-event.service.spec.ts create mode 100644 packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook.ts create mode 100644 packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook.ts create mode 100644 packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.spec.ts create mode 100644 packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service.ts rename packages/twenty-server/src/modules/calendar/common/query-hooks/calendar-event/services/{can-access-calendar-event.service.ts => can-access-calendar-events.service.ts} (81%) create mode 100644 packages/twenty-shared/src/constants/FieldRestrictedAdditionalPermissionsRequired.ts diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventNotSharedContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventNotSharedContent.tsx new file mode 100644 index 000000000..4045e1d32 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventNotSharedContent.tsx @@ -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 ( + + + + Not shared + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsAvatarGroup.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsAvatarGroup.tsx new file mode 100644 index 000000000..b7015853e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsAvatarGroup.tsx @@ -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 ( + ( + + ))} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index 675ccb6d6..e0a484dae 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -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} ) : ( - - - - Not shared - - + )} {!!calendarEvent.participants?.length && ( - ( - - ))} + )} {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;