Use twentyORM in Timeline messaging (#6595)

- Remove raw queries and replace them by using `twentyORM`
- Refactor into services and utils

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2024-08-15 10:15:32 +02:00
committed by GitHub
parent 6927f46e1c
commit 08c7947b3b
19 changed files with 513 additions and 559 deletions

View File

@ -9,7 +9,7 @@ export const useIsMatchingLocation = () => {
return useCallback(
(path: string, basePath?: AppBasePath) => {
const constructedPath = basePath
? (new URL(basePath + path, document.location.origin).pathname ?? '')
? new URL(basePath + path, document.location.origin).pathname ?? ''
: path;
return !!matchPath(constructedPath, location.pathname);

View File

@ -45,7 +45,7 @@ export const MessageThreadSubscribersChip = ({
? `+${numberOfMessageThreadSubscribers - MAX_NUMBER_OF_AVATARS}`
: null;
const label = isPrivateThread ? privateLabel : (moreAvatarsLabel ?? '');
const label = isPrivateThread ? privateLabel : moreAvatarsLabel ?? '';
return (
<Chip

View File

@ -4,7 +4,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const generateDefaultRecordChipData = (record: ObjectRecord) => {
const name = isFieldFullNameValue(record.name)
? record.name.firstName + ' ' + record.name.lastName
: (record.name ?? '');
: record.name ?? '';
return {
name,

View File

@ -42,8 +42,8 @@ export const MultiSelectFieldInput = ({
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOptions = fieldDefinition.metadata.options.filter((option) =>
fieldValues?.includes(option.value),
const selectedOptions = fieldDefinition.metadata.options.filter(
(option) => fieldValues?.includes(option.value),
);
const optionsInDropDown = fieldDefinition.metadata.options;

View File

@ -69,7 +69,7 @@ export const RecordDetailRelationSection = ({
const relationRecords: ObjectRecord[] =
fieldValue && isToOneObject
? [fieldValue as ObjectRecord]
: ((fieldValue as ObjectRecord[]) ?? []);
: (fieldValue as ObjectRecord[]) ?? [];
const relationRecordIds = relationRecords.map(({ id }) => id);

View File

@ -6,10 +6,11 @@ export const findUnmatchedRequiredFields = <T extends string>(
columns: Columns<T>,
) =>
fields
.filter((field) =>
field.fieldValidationDefinitions?.some(
(validation) => validation.rule === 'required',
),
.filter(
(field) =>
field.fieldValidationDefinitions?.some(
(validation) => validation.rule === 'required',
),
)
.filter(
(field) =>

View File

@ -16,7 +16,7 @@ type ContainerProps = {
const StyledContainer = styled.div<ContainerProps>`
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? (color ?? theme.color.blue) : theme.background.quaternary};
isOn ? color ?? theme.color.blue : theme.background.quaternary};
border-radius: 10px;
cursor: pointer;
display: flex;

View File

@ -1,14 +1,14 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('TimelineThreadParticipant')
export class TimelineThreadParticipant {
@Field(() => UUIDScalarType, { nullable: true })
personId: string;
personId: string | null;
@Field(() => UUIDScalarType, { nullable: true })
workspaceMemberId: string;
workspaceMemberId: string | null;
@Field()
firstName: string;

View File

@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/messaging/constants/messaging.constants';
import { TimelineThreadsWithTotal } from 'src/engine/core-modules/messaging/dtos/timeline-threads-with-total.dto';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/services/timeline-messaging.service';
import { formatThreads } from 'src/engine/core-modules/messaging/utils/format-threads.util';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@Injectable()
export class GetMessagesService {
constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly timelineMessagingService: TimelineMessagingService,
) {}
async getMessagesFromPersonIds(
workspaceMemberId: string,
personIds: string[],
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotal> {
const offset = (page - 1) * pageSize;
const { messageThreads, totalNumberOfThreads } =
await this.timelineMessagingService.getAndCountMessageThreads(
personIds,
offset,
pageSize,
);
if (!messageThreads) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const messageThreadIds = messageThreads.map(
(messageThread) => messageThread.id,
);
const threadParticipantsByThreadId =
await this.timelineMessagingService.getThreadParticipantsByThreadId(
messageThreadIds,
);
const threadVisibilityByThreadId =
await this.timelineMessagingService.getThreadVisibilityByThreadId(
messageThreadIds,
workspaceMemberId,
);
return {
totalNumberOfThreads,
timelineThreads: formatThreads(
messageThreads,
threadParticipantsByThreadId,
threadVisibilityByThreadId,
),
};
}
async getMessagesFromCompanyId(
workspaceMemberId: string,
companyId: string,
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotal> {
const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>(
'person',
);
const personIds = (
await personRepository.find({
where: {
companyId,
},
select: {
id: true,
},
})
).map((person) => person.id);
if (personIds.length === 0) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const messageThreads = await this.getMessagesFromPersonIds(
workspaceMemberId,
personIds,
page,
pageSize,
);
return messageThreads;
}
}

View File

@ -0,0 +1,259 @@
import { Injectable } from '@nestjs/common';
import { Any, Not } from 'typeorm';
import { TimelineThread } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
@Injectable()
export class TimelineMessagingService {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
public async getAndCountMessageThreads(
personIds: string[],
offset: number,
pageSize: number,
): Promise<{
messageThreads: Omit<
TimelineThread,
| 'firstParticipant'
| 'lastTwoParticipants'
| 'participantCount'
| 'read'
| 'visibility'
>[];
totalNumberOfThreads: number;
}> {
const messageThreadRepository =
await this.twentyORMManager.getRepository<MessageThreadWorkspaceEntity>(
'messageThread',
);
const [messageThreadIds, totalNumberOfThreads] =
await messageThreadRepository.findAndCount({
select: {
id: true,
},
where: {
messages: {
messageParticipants: {
personId: Any(personIds),
},
},
},
skip: offset,
take: pageSize,
});
const messageThreads = await messageThreadRepository.find({
select: {
id: true,
},
where: {
id: Any(messageThreadIds.map((thread) => thread.id)),
},
order: {
messages: {
receivedAt: 'DESC',
},
},
relations: ['messages'],
});
return {
messageThreads: messageThreads.map((messageThread) => {
const lastMessage = messageThread.messages[0];
const firstMessage =
messageThread.messages[messageThread.messages.length - 1];
return {
id: messageThread.id,
subject: firstMessage.subject,
lastMessageBody: lastMessage.text,
lastMessageReceivedAt: lastMessage.receivedAt ?? new Date(),
numberOfMessagesInThread: messageThread.messages.length,
};
}),
totalNumberOfThreads,
};
}
public async getThreadParticipantsByThreadId(
messageThreadIds: string[],
): Promise<{
[key: string]: MessageParticipantWorkspaceEntity[];
}> {
const messageParticipantRepository =
await this.twentyORMManager.getRepository<MessageParticipantWorkspaceEntity>(
'messageParticipant',
);
const threadParticipants = await messageParticipantRepository
.createQueryBuilder()
.select('messageParticipant')
.addSelect('message.messageThreadId')
.addSelect('message.receivedAt')
.leftJoinAndSelect('messageParticipant.person', 'person')
.leftJoinAndSelect(
'messageParticipant.workspaceMember',
'workspaceMember',
)
.leftJoin('messageParticipant.message', 'message')
.where('message.messageThreadId = ANY(:messageThreadIds)', {
messageThreadIds,
})
.andWhere('messageParticipant.role = :role', { role: 'from' })
.orderBy('message.messageThreadId')
.distinctOn(['message.messageThreadId', 'messageParticipant.handle'])
.getMany();
// This is because subqueries are not handled by twentyORM
const orderedThreadParticipants = threadParticipants.sort(
(a, b) =>
(a.message.receivedAt ?? new Date()).getTime() -
(b.message.receivedAt ?? new Date()).getTime(),
);
// This is because composite fields are not handled correctly by the ORM
const threadParticipantsWithCompositeFields = orderedThreadParticipants.map(
(threadParticipant) => ({
...threadParticipant,
person: {
id: threadParticipant.person?.id,
name: {
//eslint-disable-next-line
//@ts-ignore
firstName: threadParticipant.person?.nameFirstName,
//eslint-disable-next-line
//@ts-ignore
lastName: threadParticipant.person?.nameLastName,
},
avatarUrl: threadParticipant.person?.avatarUrl,
},
workspaceMember: {
id: threadParticipant.workspaceMember?.id,
name: {
//eslint-disable-next-line
//@ts-ignore
firstName: threadParticipant.workspaceMember?.nameFirstName,
//eslint-disable-next-line
//@ts-ignore
lastName: threadParticipant.workspaceMember?.nameLastName,
},
avatarUrl: threadParticipant.workspaceMember?.avatarUrl,
},
}),
);
return threadParticipantsWithCompositeFields.reduce(
(threadParticipantsAcc, threadParticipant) => {
if (!threadParticipant.message.messageThreadId)
return threadParticipantsAcc;
if (!threadParticipantsAcc[threadParticipant.message.messageThreadId])
threadParticipantsAcc[threadParticipant.message.messageThreadId] = [];
threadParticipantsAcc[threadParticipant.message.messageThreadId].push(
threadParticipant,
);
return threadParticipantsAcc;
},
{},
);
}
public async getThreadVisibilityByThreadId(
messageThreadIds: string[],
workspaceMemberId: string,
): Promise<{
[key: string]: MessageChannelVisibility;
}> {
const messageThreadRepository =
await this.twentyORMManager.getRepository<MessageThreadWorkspaceEntity>(
'messageThread',
);
const threadsWithoutWorkspaceMember = await messageThreadRepository.find({
select: {
id: true,
},
where: {
id: Any(messageThreadIds),
messages: {
messageChannelMessageAssociations: {
messageChannel: {
connectedAccount: {
accountOwnerId: Not(workspaceMemberId),
},
},
},
},
},
});
const threadIdsWithoutWorkspaceMember = threadsWithoutWorkspaceMember.map(
(thread) => thread.id,
);
const threadVisibility = await messageThreadRepository
.createQueryBuilder()
.select('messageThread.id', 'id')
.addSelect('messageChannel.visibility', 'visibility')
.leftJoin('messageThread.messages', 'message')
.leftJoin(
'message.messageChannelMessageAssociations',
'messageChannelMessageAssociation',
)
.leftJoin(
'messageChannelMessageAssociation.messageChannel',
'messageChannel',
)
.where('messageThread.id = ANY(:messageThreadIds)', {
messageThreadIds: threadIdsWithoutWorkspaceMember,
})
.getRawMany();
const visibilityValues = Object.values(MessageChannelVisibility);
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants:
| {
[key: string]: MessageChannelVisibility;
}
| undefined = threadVisibility?.reduce(
(threadVisibilityAcc, threadVisibility) => {
threadVisibilityAcc[threadVisibility.id] =
visibilityValues[
Math.max(
visibilityValues.indexOf(threadVisibility.visibility),
visibilityValues.indexOf(
threadVisibilityAcc[threadVisibility.id] ??
MessageChannelVisibility.METADATA,
),
)
];
return threadVisibilityAcc;
},
{},
);
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
threadVisibilityAcc[messageThreadId] =
threadIdsWithoutWorkspaceMember.includes(messageThreadId)
? (threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants?.[
messageThreadId
] ?? MessageChannelVisibility.METADATA)
: MessageChannelVisibility.SHARE_EVERYTHING;
return threadVisibilityAcc;
}, {});
return threadVisibilityByThreadId;
}
}

View File

@ -1,12 +1,17 @@
import { Module } from '@nestjs/common';
import { GetMessagesService } from 'src/engine/core-modules/messaging/services/get-messages.service';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/services/timeline-messaging.service';
import { TimelineMessagingResolver } from 'src/engine/core-modules/messaging/timeline-messaging.resolver';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/timeline-messaging.service';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@Module({
imports: [WorkspaceDataSourceModule, UserModule],
exports: [],
providers: [TimelineMessagingResolver, TimelineMessagingService],
providers: [
TimelineMessagingResolver,
TimelineMessagingService,
GetMessagesService,
],
})
export class TimelineMessagingModule {}

View File

@ -1,18 +1,16 @@
import { Args, Query, Resolver, Int, ArgsType, Field } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Args, ArgsType, Field, Int, Query, Resolver } from '@nestjs/graphql';
import { Max } from 'class-validator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { TimelineMessagingService } from 'src/engine/core-modules/messaging/timeline-messaging.service';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { TIMELINE_THREADS_MAX_PAGE_SIZE } from 'src/engine/core-modules/messaging/constants/messaging.constants';
import { TimelineThreadsWithTotal } from 'src/engine/core-modules/messaging/dtos/timeline-threads-with-total.dto';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { GetMessagesService } from 'src/engine/core-modules/messaging/services/get-messages.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
@ArgsType()
class GetTimelineThreadsFromPersonIdArgs {
@ -44,13 +42,12 @@ class GetTimelineThreadsFromCompanyIdArgs {
@Resolver(() => TimelineThreadsWithTotal)
export class TimelineMessagingResolver {
constructor(
private readonly timelineMessagingService: TimelineMessagingService,
private readonly getMessagesFromPersonIdsService: GetMessagesService,
private readonly userService: UserService,
) {}
@Query(() => TimelineThreadsWithTotal)
async getTimelineThreadsFromPersonId(
@AuthWorkspace() { id: workspaceId }: Workspace,
@AuthUser() user: User,
@Args() { personId, page, pageSize }: GetTimelineThreadsFromPersonIdArgs,
) {
@ -61,9 +58,8 @@ export class TimelineMessagingResolver {
}
const timelineThreads =
await this.timelineMessagingService.getMessagesFromPersonIds(
await this.getMessagesFromPersonIdsService.getMessagesFromPersonIds(
workspaceMember.id,
workspaceId,
[personId],
page,
pageSize,
@ -74,7 +70,6 @@ export class TimelineMessagingResolver {
@Query(() => TimelineThreadsWithTotal)
async getTimelineThreadsFromCompanyId(
@AuthWorkspace() { id: workspaceId }: Workspace,
@AuthUser() user: User,
@Args() { companyId, page, pageSize }: GetTimelineThreadsFromCompanyIdArgs,
) {
@ -85,9 +80,8 @@ export class TimelineMessagingResolver {
}
const timelineThreads =
await this.timelineMessagingService.getMessagesFromCompanyId(
await this.getMessagesFromPersonIdsService.getMessagesFromCompanyId(
workspaceMember.id,
workspaceId,
companyId,
page,
pageSize,

View File

@ -1,525 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/messaging/constants/messaging.constants';
import { TimelineThreadsWithTotal } from 'src/engine/core-modules/messaging/dtos/timeline-threads-with-total.dto';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
type TimelineThreadParticipant = {
personId: string;
workspaceMemberId: string;
firstName: string;
lastName: string;
displayName: string;
avatarUrl: string;
handle: string;
};
@Injectable()
export class TimelineMessagingService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
async getMessagesFromPersonIds(
workspaceMemberId: string,
workspaceId: string,
personIds: string[],
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotal> {
const offset = (page - 1) * pageSize;
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const messageThreads:
| {
id: string;
lastMessageReceivedAt: Date;
lastMessageId: string;
lastMessageBody: string;
rowNumber: number;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT id,
"lastMessageReceivedAt",
"lastMessageId",
"lastMessageBody"
FROM
(SELECT message."messageThreadId" AS id,
MAX(message."receivedAt") AS "lastMessageReceivedAt",
message.id AS "lastMessageId",
message.text AS "lastMessageBody",
ROW_NUMBER() OVER (PARTITION BY message."messageThreadId" ORDER BY MAX(message."receivedAt") DESC) AS "rowNumber"
FROM
${dataSourceSchema}."message" message
LEFT JOIN
${dataSourceSchema}."messageParticipant" "messageParticipant" ON "messageParticipant"."messageId" = message.id
WHERE
"messageParticipant"."personId" = ANY($1)
AND EXISTS (
SELECT 1
FROM ${dataSourceSchema}."messageChannelMessageAssociation" mcma
WHERE mcma."messageId" = message.id
)
GROUP BY
message."messageThreadId",
message.id
ORDER BY
message."receivedAt" DESC
) AS "messageThreads"
WHERE
"rowNumber" = 1
LIMIT $2
OFFSET $3
`,
[personIds, pageSize, offset],
workspaceId,
);
if (!messageThreads) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const messageThreadIds = messageThreads.map(
(messageThread) => messageThread.id,
);
const threadSubjects:
| {
id: string;
subject: string;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT *
FROM
(SELECT
message."messageThreadId" AS id,
message.subject,
ROW_NUMBER() OVER (PARTITION BY message."messageThreadId" ORDER BY MAX(message."receivedAt") ASC) AS "rowNumber"
FROM
${dataSourceSchema}."message" message
WHERE
message."messageThreadId" = ANY($1)
GROUP BY
message."messageThreadId",
message.id
ORDER BY
message."receivedAt" DESC
) AS "messageThreads"
WHERE
"rowNumber" = 1
`,
[messageThreadIds],
workspaceId,
);
const numberOfMessagesInThread:
| {
id: string;
numberOfMessagesInThread: number;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT
message."messageThreadId" AS id,
COUNT(message.id) AS "numberOfMessagesInThread"
FROM
${dataSourceSchema}."message" message
WHERE
message."messageThreadId" = ANY($1)
AND EXISTS (
SELECT 1
FROM ${dataSourceSchema}."messageChannelMessageAssociation" mcma
WHERE mcma."messageId" = message.id
)
GROUP BY
message."messageThreadId"
`,
[messageThreadIds],
workspaceId,
);
const messageThreadsByMessageThreadId: {
[key: string]: {
id: string;
lastMessageReceivedAt: Date;
lastMessageBody: string;
};
} = messageThreads.reduce((messageThreadAcc, messageThread) => {
messageThreadAcc[messageThread.id] = messageThread;
return messageThreadAcc;
}, {});
const subjectsByMessageThreadId:
| {
[key: string]: {
id: string;
subject: string;
};
}
| undefined = threadSubjects?.reduce(
(threadSubjectAcc, threadSubject) => {
threadSubjectAcc[threadSubject.id] = threadSubject;
return threadSubjectAcc;
},
{},
);
const numberOfMessagesByMessageThreadId:
| {
[key: string]: {
id: string;
numberOfMessagesInThread: number;
};
}
| undefined = numberOfMessagesInThread?.reduce(
(numberOfMessagesAcc, numberOfMessages) => {
numberOfMessagesAcc[numberOfMessages.id] = numberOfMessages;
return numberOfMessagesAcc;
},
{},
);
const threadMessagesParticipants:
| {
id: string;
messageId: string;
receivedAt: Date;
body: string;
subject: string;
role: string;
personId: string;
workspaceMemberId: string;
handle: string;
personFirstName: string;
personLastName: string;
personAvatarUrl: string;
workspaceMemberFirstName: string;
workspaceMemberLastName: string;
workspaceMemberAvatarUrl: string;
messageDisplayName: string;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT DISTINCT message."messageThreadId" AS id,
message.id AS "messageId",
message."receivedAt",
message.text,
message."subject",
"messageParticipant"."role",
"messageParticipant"."personId",
"messageParticipant"."workspaceMemberId",
"messageParticipant".handle,
"person"."nameFirstName" as "personFirstName",
"person"."nameLastName" as "personLastName",
"person"."avatarUrl" as "personAvatarUrl",
"workspaceMember"."nameFirstName" as "workspaceMemberFirstName",
"workspaceMember"."nameLastName" as "workspaceMemberLastName",
"workspaceMember"."avatarUrl" as "workspaceMemberAvatarUrl",
"messageParticipant"."displayName" as "messageDisplayName"
FROM
${dataSourceSchema}."message" message
LEFT JOIN
${dataSourceSchema}."messageParticipant" "messageParticipant" ON "messageParticipant"."messageId" = message.id
LEFT JOIN
${dataSourceSchema}."person" person ON person."id" = "messageParticipant"."personId"
LEFT JOIN
${dataSourceSchema}."workspaceMember" "workspaceMember" ON "workspaceMember".id = "messageParticipant"."workspaceMemberId"
WHERE
message."messageThreadId" = ANY($1)
ORDER BY
message."receivedAt" DESC
`,
[messageThreadIds],
workspaceId,
);
const threadMessagesFromActiveParticipants =
threadMessagesParticipants?.filter(
(threadMessage) => threadMessage.role === 'from',
);
const totalNumberOfThreads =
await this.workspaceDataSourceService.executeRawQuery(
`
SELECT COUNT(DISTINCT message."messageThreadId")
FROM
${dataSourceSchema}."message" message
LEFT JOIN
${dataSourceSchema}."messageParticipant" "messageParticipant" ON "messageParticipant"."messageId" = message.id
WHERE
"messageParticipant"."personId" = ANY($1)
AND EXISTS (
SELECT 1
FROM ${dataSourceSchema}."messageChannelMessageAssociation" mcma
WHERE mcma."messageId" = message.id
)
`,
[personIds],
workspaceId,
);
const threadActiveParticipantsByThreadId: {
[key: string]: TimelineThreadParticipant[];
} = messageThreadIds.reduce((messageThreadIdAcc, messageThreadId) => {
const threadMessages = threadMessagesFromActiveParticipants?.filter(
(threadMessage) => threadMessage.id === messageThreadId,
);
const threadActiveParticipants = threadMessages?.reduce(
(
threadMessageAcc,
threadMessage,
): {
[key: string]: TimelineThreadParticipant;
} => {
const threadParticipant = threadMessageAcc[threadMessage.handle];
const firstName =
threadMessage.personFirstName ||
threadMessage.workspaceMemberFirstName ||
'';
const lastName =
threadMessage.personLastName ||
threadMessage.workspaceMemberLastName ||
'';
const displayName =
firstName ||
threadMessage.messageDisplayName ||
threadMessage.handle;
if (!threadParticipant) {
threadMessageAcc[threadMessage.handle] = {
personId: threadMessage.personId,
workspaceMemberId: threadMessage.workspaceMemberId,
firstName,
lastName,
displayName,
avatarUrl:
threadMessage.personAvatarUrl ??
threadMessage.workspaceMemberAvatarUrl ??
'',
handle: threadMessage.handle,
};
}
return threadMessageAcc;
},
{},
);
messageThreadIdAcc[messageThreadId] = threadActiveParticipants
? Object.values(threadActiveParticipants)
: [];
return messageThreadIdAcc;
}, {});
const messageThreadIdsForWhichWorkspaceMemberIsNotInParticipants =
messageThreadIds.reduce(
(
messageThreadIdsForWhichWorkspaceMemberIsInNotParticipantsAcc: string[],
messageThreadId,
) => {
const threadMessagesWithWorkspaceMemberInParticipants =
threadMessagesParticipants?.filter(
(threadMessage) =>
threadMessage.id === messageThreadId &&
threadMessage.workspaceMemberId === workspaceMemberId,
) ?? [];
if (threadMessagesWithWorkspaceMemberInParticipants.length === 0)
messageThreadIdsForWhichWorkspaceMemberIsInNotParticipantsAcc.push(
messageThreadId,
);
return messageThreadIdsForWhichWorkspaceMemberIsInNotParticipantsAcc;
},
[],
);
const threadVisibility:
| {
id: string;
visibility: MessageChannelVisibility;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT
message."messageThreadId" AS id,
"messageChannel".visibility
FROM
${dataSourceSchema}."message" message
LEFT JOIN
${dataSourceSchema}."messageChannelMessageAssociation" "messageChannelMessageAssociation" ON "messageChannelMessageAssociation"."messageId" = message.id
LEFT JOIN
${dataSourceSchema}."messageChannel" "messageChannel" ON "messageChannel".id = "messageChannelMessageAssociation"."messageChannelId"
WHERE
message."messageThreadId" = ANY($1)
`,
[messageThreadIdsForWhichWorkspaceMemberIsNotInParticipants],
workspaceId,
);
const visibilityValues = Object.values(MessageChannelVisibility);
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants:
| {
[key: string]: MessageChannelVisibility;
}
| undefined = threadVisibility?.reduce(
(threadVisibilityAcc, threadVisibility) => {
threadVisibilityAcc[threadVisibility.id] =
visibilityValues[
Math.max(
visibilityValues.indexOf(threadVisibility.visibility),
visibilityValues.indexOf(
threadVisibilityAcc[threadVisibility.id] ??
MessageChannelVisibility.METADATA,
),
)
];
return threadVisibilityAcc;
},
{},
);
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
threadVisibilityAcc[messageThreadId] =
messageThreadIdsForWhichWorkspaceMemberIsNotInParticipants.includes(
messageThreadId,
)
? (threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants?.[
messageThreadId
] ?? MessageChannelVisibility.METADATA)
: MessageChannelVisibility.SHARE_EVERYTHING;
return threadVisibilityAcc;
}, {});
const timelineThreads = messageThreadIds.map((messageThreadId) => {
const threadActiveParticipants =
threadActiveParticipantsByThreadId[messageThreadId];
const firstParticipant = threadActiveParticipants[0];
const threadActiveParticipantsWithoutFirstParticipant =
threadActiveParticipants.filter(
(threadParticipant) =>
threadParticipant.handle !== firstParticipant.handle,
);
const lastTwoParticipants: TimelineThreadParticipant[] = [];
const lastParticipant =
threadActiveParticipantsWithoutFirstParticipant.slice(-1)[0];
if (lastParticipant) {
lastTwoParticipants.push(lastParticipant);
const threadActiveParticipantsWithoutFirstAndLastParticipants =
threadActiveParticipantsWithoutFirstParticipant.filter(
(threadParticipant) =>
threadParticipant.handle !== lastParticipant.handle,
);
if (threadActiveParticipantsWithoutFirstAndLastParticipants.length > 0)
lastTwoParticipants.push(
threadActiveParticipantsWithoutFirstAndLastParticipants.slice(
-1,
)[0],
);
}
const thread = messageThreadsByMessageThreadId[messageThreadId];
const threadSubject =
subjectsByMessageThreadId?.[messageThreadId].subject ?? '';
const numberOfMessages =
numberOfMessagesByMessageThreadId?.[messageThreadId]
.numberOfMessagesInThread ?? 1;
return {
id: messageThreadId,
read: true,
firstParticipant,
lastTwoParticipants,
lastMessageReceivedAt: thread.lastMessageReceivedAt,
lastMessageBody: thread.lastMessageBody,
visibility:
threadVisibilityByThreadId?.[messageThreadId] ??
MessageChannelVisibility.METADATA,
subject: threadSubject,
numberOfMessagesInThread: numberOfMessages,
participantCount: threadActiveParticipants.length,
};
});
return {
totalNumberOfThreads: totalNumberOfThreads[0]?.count ?? 0,
timelineThreads,
};
}
async getMessagesFromCompanyId(
workspaceMemberId: string,
workspaceId: string,
companyId: string,
page = 1,
pageSize: number = TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
): Promise<TimelineThreadsWithTotal> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const personIds = await this.workspaceDataSourceService.executeRawQuery(
`
SELECT
p."id"
FROM
${dataSourceSchema}."person" p
WHERE
p."companyId" = $1
`,
[companyId],
workspaceId,
);
if (!personIds) {
return {
totalNumberOfThreads: 0,
timelineThreads: [],
};
}
const formattedPersonIds = personIds.map(
(personId: { id: string }) => personId.id,
);
const messageThreads = await this.getMessagesFromPersonIds(
workspaceMemberId,
workspaceId,
formattedPersonIds,
page,
pageSize,
);
return messageThreads;
}
}

View File

@ -0,0 +1,54 @@
import { TimelineThreadParticipant } from 'src/engine/core-modules/messaging/dtos/timeline-thread-participant.dto';
import { filterActiveParticipants } from 'src/engine/core-modules/messaging/utils/filter-active-participants.util';
import { formatThreadParticipant } from 'src/engine/core-modules/messaging/utils/format-thread-participant.util';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
export const extractParticipantSummary = (
messageParticipants: MessageParticipantWorkspaceEntity[],
): {
firstParticipant: TimelineThreadParticipant;
lastTwoParticipants: TimelineThreadParticipant[];
participantCount: number;
} => {
const activeMessageParticipants =
filterActiveParticipants(messageParticipants);
const firstParticipant = formatThreadParticipant(
activeMessageParticipants[0],
);
const activeMessageParticipantsWithoutFirstParticipant =
activeMessageParticipants.filter(
(threadParticipant) =>
threadParticipant.handle !== firstParticipant.handle,
);
const lastTwoParticipants: TimelineThreadParticipant[] = [];
const lastParticipant =
activeMessageParticipantsWithoutFirstParticipant.slice(-1)[0];
if (lastParticipant) {
lastTwoParticipants.push(formatThreadParticipant(lastParticipant));
const activeMessageParticipantsWithoutFirstAndLastParticipants =
activeMessageParticipantsWithoutFirstParticipant.filter(
(threadParticipant) =>
threadParticipant.handle !== lastParticipant.handle,
);
if (activeMessageParticipantsWithoutFirstAndLastParticipants.length > 0) {
lastTwoParticipants.push(
formatThreadParticipant(
activeMessageParticipantsWithoutFirstAndLastParticipants.slice(-1)[0],
),
);
}
}
return {
firstParticipant,
lastTwoParticipants,
participantCount: activeMessageParticipants.length,
};
};

View File

@ -0,0 +1,7 @@
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
export const filterActiveParticipants = (
participants: MessageParticipantWorkspaceEntity[],
): MessageParticipantWorkspaceEntity[] => {
return participants.filter((participant) => participant.role === 'from');
};

View File

@ -0,0 +1,30 @@
import { TimelineThreadParticipant } from 'src/engine/core-modules/messaging/dtos/timeline-thread-participant.dto';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
export const formatThreadParticipant = (
threadParticipant: MessageParticipantWorkspaceEntity,
): TimelineThreadParticipant => ({
personId: threadParticipant.personId,
workspaceMemberId: threadParticipant.workspaceMemberId,
firstName:
threadParticipant.person?.name?.firstName ||
threadParticipant.workspaceMember?.name.firstName ||
'',
lastName:
threadParticipant.person?.name?.lastName ||
threadParticipant.workspaceMember?.name.lastName ||
'',
displayName:
threadParticipant.person?.name?.firstName ||
threadParticipant.person?.name?.lastName ||
threadParticipant.workspaceMember?.name.firstName ||
threadParticipant.workspaceMember?.name.lastName ||
threadParticipant.displayName ||
threadParticipant.handle ||
'',
avatarUrl:
threadParticipant.person?.avatarUrl ||
threadParticipant.workspaceMember?.avatarUrl ||
'',
handle: threadParticipant.handle,
});

View File

@ -0,0 +1,28 @@
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';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
export const formatThreads = (
threads: Omit<
TimelineThread,
| 'firstParticipant'
| 'lastTwoParticipants'
| 'participantCount'
| 'read'
| 'visibility'
>[],
threadParticipantsByThreadId: {
[key: string]: MessageParticipantWorkspaceEntity[];
},
threadVisibilityByThreadId: {
[key: string]: MessageChannelVisibility;
},
): TimelineThread[] => {
return threads.map((thread) => ({
...thread,
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
visibility: threadVisibilityByThreadId[thread.id],
read: true,
}));
};

View File

@ -4,6 +4,7 @@ import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -30,7 +31,6 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.company,
@ -132,7 +132,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
position: number | null;
position: number;
@WorkspaceField({
standardId: COMPANY_STANDARD_FIELD_IDS.createdBy,

View File

@ -127,7 +127,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
position: number | null;
position: number;
@WorkspaceField({
standardId: PERSON_STANDARD_FIELD_IDS.createdBy,