Ej/fix message visibility (#11874)

<img width="257" alt="Screenshot 2025-05-05 at 15 30 09"
src="https://github.com/user-attachments/assets/5a8e18e0-efc5-4521-9c3a-bf73277ecdf9"
/>
<img width="257" alt="Screenshot 2025-05-05 at 15 29 05"
src="https://github.com/user-attachments/assets/c1a784af-a744-497a-b6ce-ec3a9e8b851a"
/>
<img width="257" alt="Screenshot 2025-05-05 at 15 33 06"
src="https://github.com/user-attachments/assets/c5fabd1d-a125-49d7-aade-0a208a0eff95"
/>

related to PR https://github.com/twentyhq/twenty/pull/11840 and issue
https://github.com/twentyhq/twenty/issues/9826
This commit is contained in:
Etienne
2025-05-05 17:23:27 +02:00
committed by GitHub
parent da0c7e679e
commit a60711c808
19 changed files with 578 additions and 481 deletions

View File

@ -1,11 +1,12 @@
import styled from '@emotion/styled';
import { isUndefined } from '@sniptt/guards';
import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage';
import { EventCardMessageNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageNotShared';
import { EventCardMessageBodyNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageBodyNotShared';
import { EventCardMessageForbidden } from '@/activities/timeline-activities/rows/message/components/EventCardMessageForbidden';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
@ -85,7 +86,7 @@ export const EventCardMessage = ({
);
if (shouldHideMessageContent) {
return <EventCardMessageNotShared sharedByFullName={authorFullName} />;
return <EventCardMessageForbidden notSharedByFullName={authorFullName} />;
}
const shouldHandleNotFound = error.graphQLErrors.some(
@ -99,7 +100,7 @@ export const EventCardMessage = ({
return <div>Error loading message</div>;
}
if (loading || isUndefined(message)) {
if (loading || !isDefined(message)) {
return <div>Loading...</div>;
}
@ -112,12 +113,21 @@ export const EventCardMessage = ({
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTop>
<StyledEmailTitle>{message.subject}</StyledEmailTitle>
<StyledEmailTitle>
{message.subject !==
FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED
? message.subject
: 'Subject not shared'}
</StyledEmailTitle>
<StyledEmailParticipants>
<OverflowingTextWithTooltip text={messageParticipantHandles} />
</StyledEmailParticipants>
</StyledEmailTop>
<StyledEmailBody>{message.text}</StyledEmailBody>
{message.text !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED ? (
<StyledEmailBody>{message.text}</StyledEmailBody>
) : (
<EventCardMessageBodyNotShared notSharedByFullName={authorFullName} />
)}
</StyledEmailContent>
</StyledEventCardMessageContainer>
);

View File

@ -0,0 +1,51 @@
import styled from '@emotion/styled';
import { IconLock } from 'twenty-ui/display';
const StyledEmailBodyNotSharedContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
height: 80px;
justify-content: center;
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
`;
const StyledEmailBodyNotSharedIconContainer = styled.div`
display: flex;
width: ${({ theme }) => theme.icon.size.sm}px;
height: ${({ theme }) => theme.icon.size.sm}px;
justify-content: center;
align-items: center;
`;
const StyledEmailBodyNotShared = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const EventCardMessageBodyNotShared = ({
notSharedByFullName,
}: {
notSharedByFullName: string;
}) => {
return (
<StyledEmailBodyNotSharedContainer>
<StyledEmailBodyNotShared>
<StyledEmailBodyNotSharedIconContainer>
<IconLock />
</StyledEmailBodyNotSharedIconContainer>
<span>Not shared by {notSharedByFullName}</span>
</StyledEmailBodyNotShared>
</StyledEmailBodyNotSharedContainer>
);
};

View File

@ -0,0 +1,43 @@
import { EventCardMessageBodyNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageBodyNotShared';
import styled from '@emotion/styled';
const StyledEventCardMessageContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledEmailContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: center;
width: 100%;
`;
const StyledEmailTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
flex-direction: column;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const EventCardMessageForbidden = ({
notSharedByFullName,
}: {
notSharedByFullName: string;
}) => {
return (
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTitle>
<span>Subject not shared</span>
</StyledEmailTitle>
<EventCardMessageBodyNotShared
notSharedByFullName={notSharedByFullName}
/>
</StyledEmailContent>
</StyledEventCardMessageContainer>
);
};

View File

@ -1,86 +0,0 @@
import styled from '@emotion/styled';
import { IconLock } from 'twenty-ui/display';
const StyledEventCardMessageContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledEmailContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: center;
`;
const StyledEmailTop = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledEmailTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
`;
const StyledEmailBodyNotShareContainer = styled.div`
align-items: center;
align-self: stretch;
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
height: 80px;
justify-content: center;
padding: 0 ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledEmailBodyNotSharedIconContainer = styled.div`
display: flex;
width: 14px;
height: 14px;
justify-content: center;
align-items: center;
`;
const StyledEmailBodyNotShare = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: 0 ${({ theme }) => theme.spacing(1)};
`;
export const EventCardMessageNotShared = ({
sharedByFullName,
}: {
sharedByFullName: string;
}) => {
return (
<StyledEventCardMessageContainer>
<StyledEmailContent>
<StyledEmailTop>
<StyledEmailTitle>
<span>Subject not shared</span>
</StyledEmailTitle>
</StyledEmailTop>
<StyledEmailBodyNotShareContainer>
<StyledEmailBodyNotShare>
<StyledEmailBodyNotSharedIconContainer>
<IconLock />
</StyledEmailBodyNotSharedIconContainer>
<span>Not shared by {sharedByFullName}</span>
</StyledEmailBodyNotShare>
</StyledEmailBodyNotShareContainer>
</StyledEmailContent>
</StyledEventCardMessageContainer>
);
};

View File

@ -222,7 +222,7 @@ export class TimelineMessagingService {
const visibilityValues = Object.values(MessageChannelVisibility);
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants:
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotOwner:
| {
[key: string]: MessageChannelVisibility;
}
@ -247,10 +247,10 @@ export class TimelineMessagingService {
const threadVisibilityByThreadId: {
[key: string]: MessageChannelVisibility;
} = messageThreadIds.reduce((threadVisibilityAcc, messageThreadId) => {
// If the workspace member is not in the participants of the thread, use the visibility value from the query
// If the workspace member is not the owner of the thread, use the visibility value from the query
threadVisibilityAcc[messageThreadId] =
threadIdsWithoutWorkspaceMember.includes(messageThreadId)
? (threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants?.[
? (threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotOwner?.[
messageThreadId
] ?? MessageChannelVisibility.METADATA)
: MessageChannelVisibility.SHARE_EVERYTHING;

View File

@ -1,3 +1,5 @@
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { TimelineThread } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { extractParticipantSummary } from 'src/engine/core-modules/messaging/utils/extract-participant-summary.util';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@ -19,10 +21,23 @@ export const formatThreads = (
[key: string]: MessageChannelVisibility;
},
): TimelineThread[] => {
return threads.map((thread) => ({
...thread,
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
visibility: threadVisibilityByThreadId[thread.id],
read: true,
}));
return threads.map((thread) => {
const visibility = threadVisibilityByThreadId[thread.id];
return {
...thread,
subject:
visibility === MessageChannelVisibility.SHARE_EVERYTHING ||
visibility === MessageChannelVisibility.SUBJECT
? thread.subject
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
lastMessageBody:
visibility === MessageChannelVisibility.SHARE_EVERYTHING
? thread.lastMessageBody
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
visibility,
read: true,
};
});
};

View File

@ -1,62 +0,0 @@
import { BadRequestException, NotFoundException, Scope } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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 { 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({
key: `calendarEvent.findMany`,
scope: Scope.REQUEST,
})
export class CalendarEventFindManyPreQueryHook
implements WorkspaceQueryHookInstance
{
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly canAccessCalendarEventsService: CanAccessCalendarEventsService,
) {}
async execute(
authContext: AuthContext,
objectName: string,
payload: FindManyResolverArgs,
): Promise<FindManyResolverArgs> {
if (!payload?.filter?.id?.eq) {
throw new BadRequestException('id filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const calendarChannelEventAssociationRepository =
await this.twentyORMManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
'calendarChannelEventAssociation',
);
const calendarChannelCalendarEventAssociations =
await calendarChannelEventAssociationRepository.find({
where: {
calendarEventId: payload?.filter?.id?.eq,
},
relations: ['calendarChannel.connectedAccount'],
});
if (calendarChannelCalendarEventAssociations.length === 0) {
throw new NotFoundException();
}
await this.canAccessCalendarEventsService.canAccessCalendarEvents(
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,
);
return payload;
}
}

View File

@ -1,62 +0,0 @@
import { BadRequestException, NotFoundException, Scope } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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 { 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({
key: `calendarEvent.findOne`,
scope: Scope.REQUEST,
})
export class CalendarEventFindOnePreQueryHook
implements WorkspaceQueryHookInstance
{
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly canAccessCalendarEventsService: CanAccessCalendarEventsService,
) {}
async execute(
authContext: AuthContext,
objectName: string,
payload: FindOneResolverArgs,
): Promise<FindOneResolverArgs> {
if (!payload?.filter?.id?.eq) {
throw new BadRequestException('id filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const calendarChannelEventAssociationRepository =
await this.twentyORMManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
'calendarChannelEventAssociation',
);
const calendarChannelCalendarEventAssociations =
await calendarChannelEventAssociationRepository.find({
where: {
calendarEventId: payload?.filter?.id?.eq,
},
relations: ['calendarChannel', 'calendarChannel.connectedAccount'],
});
if (calendarChannelCalendarEventAssociations.length === 0) {
throw new NotFoundException();
}
await this.canAccessCalendarEventsService.canAccessCalendarEvents(
authContext.user.id,
authContext.workspace.id,
calendarChannelCalendarEventAssociations,
);
return payload;
}
}

View File

@ -28,7 +28,7 @@ export class ApplyCalendarEventsVisibilityRestrictionsService {
where: {
calendarEventId: In(calendarEvents.map((event) => event.id)),
},
relations: ['calendarChannel', 'calendarChannel.connectedAccount'],
relations: ['calendarChannel'],
});
const connectedAccountRepository =

View File

@ -1,70 +0,0 @@
import { Injectable } from '@nestjs/common';
import groupBy from 'lodash.groupby';
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';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class CanAccessCalendarEventsService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberService: WorkspaceMemberRepository,
) {}
public async canAccessCalendarEvents(
userId: string,
workspaceId: string,
calendarChannelCalendarEventAssociations: CalendarChannelEventAssociationWorkspaceEntity[],
) {
const calendarChannels = calendarChannelCalendarEventAssociations.map(
(association) => association.calendarChannel,
);
const calendarChannelsGroupByVisibility = groupBy(
calendarChannels,
(channel) => channel.visibility,
);
if (
calendarChannelsGroupByVisibility[
CalendarChannelVisibility.SHARE_EVERYTHING
] ||
calendarChannelsGroupByVisibility[CalendarChannelVisibility.METADATA]
) {
return;
}
const currentWorkspaceMember =
await this.workspaceMemberService.getByIdOrFail(userId, workspaceId);
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
calendarChannels: {
id: In(calendarChannels.map((channel) => channel.id)),
},
accountOwnerId: currentWorkspaceMember.id,
},
});
if (connectedAccounts.length > 0) {
return;
}
throw new ForbiddenError('Calendar events not shared');
}
}

View File

@ -2,11 +2,8 @@ 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 { 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({
@ -14,10 +11,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
],
providers: [
CanAccessCalendarEventsService,
ApplyCalendarEventsVisibilityRestrictionsService,
CalendarEventFindOnePreQueryHook,
CalendarEventFindManyPreQueryHook,
CalendarEventFindOnePostQueryHook,
CalendarEventFindManyPostQueryHook,
],

View File

@ -0,0 +1,269 @@
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 { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { ApplyMessagesVisibilityRestrictionsService } from './apply-messages-visibility-restrictions.service';
const createMockMessage = (
id: string,
subject: string,
text: string,
): MessageWorkspaceEntity => ({
id,
subject,
text,
headerMessageId: '',
receivedAt: new Date('2024-03-20T10:00:00Z'),
messageThreadId: '',
messageThread: null,
messageChannelMessageAssociations: [],
messageParticipants: [],
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
});
describe('ApplyMessagesVisibilityRestrictionsService', () => {
let service: ApplyMessagesVisibilityRestrictionsService;
const mockMessageChannelMessageAssociationRepository = {
find: jest.fn(),
};
const mockConnectedAccountRepository = {
find: jest.fn(),
};
const mockTwentyORMManager = {
getRepository: jest.fn().mockImplementation((name) => {
if (name === 'messageChannelMessageAssociation') {
return mockMessageChannelMessageAssociationRepository;
}
if (name === 'connectedAccount') {
return mockConnectedAccountRepository;
}
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplyMessagesVisibilityRestrictionsService,
{
provide: TwentyORMManager,
useValue: mockTwentyORMManager,
},
],
}).compile();
service = module.get<ApplyMessagesVisibilityRestrictionsService>(
ApplyMessagesVisibilityRestrictionsService,
);
jest.clearAllMocks();
});
it('should return message without obfuscated subject and text if the visibility is SHARE_EVERYTHING', async () => {
const messages = [
createMockMessage('messageId', 'Test Subject', 'Test Message'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: 'messageId',
messageChannel: {
id: 'messageChannelId',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
},
},
]);
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual(messages);
expect(
result.every(
(item) =>
item.subject === 'Test Subject' && item.text === 'Test Message',
),
).toBe(true);
expect(mockConnectedAccountRepository.find).not.toHaveBeenCalled();
});
it('should return message without obfuscated subject and with obfuscated text if the visibility is SUBJECT', async () => {
const messages = [
createMockMessage('messageId', 'Test Subject', 'Test Message'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: 'messageId',
messageChannel: {
id: 'messageChannelId',
visibility: MessageChannelVisibility.SUBJECT,
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([]);
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual([
{
...messages[0],
text: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
]);
});
it('should return message with obfuscated subject and text if the visibility is METADATA', async () => {
const messages = [
createMockMessage('messageId', 'Test Subject', 'Test Message'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: 'messageId',
messageChannel: {
id: 'messageChannelId',
visibility: MessageChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([]);
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual([
{
...messages[0],
subject: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
text: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
]);
});
it('should return message without obfuscated subject and text if the visibility is METADATA and the workspace member is the channel owner', async () => {
const messages = [
createMockMessage('messageId', 'Test Subject', 'Test Message'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: 'messageId',
messageChannel: {
id: 'messageChannelId',
visibility: MessageChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([{ id: '1' }]);
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual(messages);
expect(
result.every(
(item) =>
item.subject === 'Test Subject' && item.text === 'Test Message',
),
).toBe(true);
});
it('should not return message if visibility is not SHARE_EVERYTHING, SUBJECT or METADATA and the workspace member is not the channel owner', async () => {
const messages = [
createMockMessage('messageId', 'Test Subject', 'Test Message'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: 'messageId',
messageChannel: {
id: 'messageChannelId',
},
},
]);
mockConnectedAccountRepository.find.mockResolvedValue([]);
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual([]);
});
it('should return all messages with the right visibility', async () => {
const messages = [
createMockMessage('1', 'Subject 1', 'Message 1'),
createMockMessage('2', 'Subject 2', 'Message 2'),
createMockMessage('3', 'Subject 3', 'Message 3'),
];
mockMessageChannelMessageAssociationRepository.find.mockResolvedValue([
{
messageId: '1',
messageChannel: {
id: '1',
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
},
},
{
messageId: '2',
messageChannel: {
id: '2',
visibility: MessageChannelVisibility.SUBJECT,
},
},
{
messageId: '3',
messageChannel: {
id: '3',
visibility: MessageChannelVisibility.METADATA,
},
},
]);
mockConnectedAccountRepository.find
.mockResolvedValueOnce([]) // request for message 3
.mockResolvedValueOnce([]); // request for message 2
const result = await service.applyMessagesVisibilityRestrictions(
'workspace-member-id',
messages,
);
expect(result).toEqual([
messages[0],
{
...messages[1],
text: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
{
...messages[2],
subject: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
text: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
},
]);
});
});

View File

@ -0,0 +1,98 @@
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 { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
@Injectable()
export class ApplyMessagesVisibilityRestrictionsService {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
public async applyMessagesVisibilityRestrictions(
workspaceMemberId: string,
messages: MessageWorkspaceEntity[],
) {
const messageChannelMessageAssociationRepository =
await this.twentyORMManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
'messageChannelMessageAssociation',
);
const messageChannelMessagesAssociations =
await messageChannelMessageAssociationRepository.find({
where: {
messageId: In(messages.map((message) => message.id)),
},
relations: ['messageChannel'],
});
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
for (let i = messages.length - 1; i >= 0; i--) {
const messageChannelMessageAssociations =
messageChannelMessagesAssociations.filter(
(association) => association.messageId === messages[i].id,
);
const messageChannels = messageChannelMessageAssociations
.map((association) => association.messageChannel)
.filter(
(channel): channel is NonNullable<typeof channel> => channel !== null,
);
if (messageChannels.length === 0) {
throw new NotFoundError('Associated message channels not found');
}
const messageChannelsGroupByVisibility = groupBy(
messageChannels,
(channel) => channel.visibility,
);
if (
messageChannelsGroupByVisibility[
MessageChannelVisibility.SHARE_EVERYTHING
]
) {
continue;
}
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
messageChannels: {
id: In(messageChannels.map((channel) => channel.id)),
},
accountOwnerId: workspaceMemberId,
},
});
if (connectedAccounts.length > 0) {
continue;
}
if (messageChannelsGroupByVisibility[MessageChannelVisibility.SUBJECT]) {
messages[i].text = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
continue;
}
if (messageChannelsGroupByVisibility[MessageChannelVisibility.METADATA]) {
messages[i].subject = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
messages[i].text = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
continue;
}
messages.splice(i, 1);
}
return messages;
}
}

View File

@ -1,62 +0,0 @@
import { ForbiddenException } from '@nestjs/common';
import { In } from 'typeorm';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export class CanAccessMessageThreadService {
constructor(
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberRepository: WorkspaceMemberRepository,
private readonly twentyORMManager: TwentyORMManager,
) {}
public async canAccessMessageThread(
userId: string,
workspaceId: string,
messageChannelMessageAssociations: MessageChannelMessageAssociationWorkspaceEntity[],
) {
const messageChannelIds = messageChannelMessageAssociations.map(
(association) => association.messageChannelId,
);
const currentWorkspaceMember =
await this.workspaceMemberRepository.getByIdOrFail(userId, workspaceId);
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
const connectedAccounts = await connectedAccountRepository.find({
select: {
id: true,
},
where: [
{
messageChannels: {
id: In(messageChannelIds),
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
},
},
{
messageChannels: {
id: In(messageChannelIds),
},
accountOwnerId: currentWorkspaceMember.id,
},
],
take: 1,
});
if (connectedAccounts.length === 0) {
throw new ForbiddenException();
}
}
}

View File

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

View File

@ -1,58 +0,0 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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 { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
@WorkspaceQueryHook(`message.findMany`)
export class MessageFindManyPreQueryHook implements WorkspaceQueryHookInstance {
constructor(
private readonly canAccessMessageThreadService: CanAccessMessageThreadService,
private readonly twentyORMManager: TwentyORMManager,
) {}
async execute(
authContext: AuthContext,
objectName: string,
payload: FindManyResolverArgs,
): Promise<FindManyResolverArgs> {
if (!payload?.filter?.messageThreadId?.eq) {
throw new BadRequestException('messageThreadId filter is required');
}
if (!authContext.user?.id) {
throw new BadRequestException('User id is required');
}
const messageChannelMessageAssociationRepository =
await this.twentyORMManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
'messageChannelMessageAssociation',
);
const messageChannelMessageAssociations =
await messageChannelMessageAssociationRepository.find({
where: {
message: {
messageThreadId: payload.filter.messageThreadId.eq,
},
},
});
if (messageChannelMessageAssociations.length === 0) {
throw new NotFoundException();
}
await this.canAccessMessageThreadService.canAccessMessageThread(
authContext.user.id,
authContext.workspace.id,
messageChannelMessageAssociations,
);
return payload;
}
}

View File

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

View File

@ -1,53 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { NotFoundException } from '@nestjs/common';
import { WorkspaceQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { FindOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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 { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
@WorkspaceQueryHook(`message.findOne`)
export class MessageFindOnePreQueryHook implements WorkspaceQueryHookInstance {
constructor(
private readonly canAccessMessageThreadService: CanAccessMessageThreadService,
private readonly twentyORMManager: TwentyORMManager,
) {}
async execute(
authContext: AuthContext,
objectName: string,
payload: FindOneResolverArgs,
): Promise<FindOneResolverArgs> {
if (!authContext.user?.id) {
throw new NotFoundException('User id is required');
}
const messageChannelMessageAssociationRepository =
await this.twentyORMManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
'messageChannelMessageAssociation',
);
const messageChannelMessageAssociations =
await messageChannelMessageAssociationRepository.find({
where: {
messageId: payload?.filter?.id?.eq,
},
});
if (messageChannelMessageAssociations.length === 0) {
throw new NotFoundException();
}
await this.canAccessMessageThreadService.canAccessMessageThread(
authContext.user.id,
authContext.workspace.id,
messageChannelMessageAssociations,
);
return payload;
}
}

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { CanAccessMessageThreadService } from 'src/modules/messaging/common/query-hooks/message/can-access-message-thread.service';
import { MessageFindManyPreQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-many.pre-query.hook';
import { MessageFindOnePreQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-one.pre-query-hook';
import { ApplyMessagesVisibilityRestrictionsService } from 'src/modules/messaging/common/query-hooks/message/apply-messages-visibility-restrictions.service';
import { MessageFindManyPostQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-many.post-query.hook';
import { MessageFindOnePostQueryHook } from 'src/modules/messaging/common/query-hooks/message/message-find-one.post-query.hook';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
@ -11,9 +11,9 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
],
providers: [
CanAccessMessageThreadService,
MessageFindOnePreQueryHook,
MessageFindManyPreQueryHook,
ApplyMessagesVisibilityRestrictionsService,
MessageFindOnePostQueryHook,
MessageFindManyPostQueryHook,
],
})
export class MessagingQueryHookModule {}