Files
twenty_crm/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts
Paul Rastoin a8423e8503 [QRQC_2] No explicit any in twenty-server (#12068)
# Introduction

Added a no-explicit-any rule to the twenty-server, not applicable to
tests and integration tests folder

Related to https://github.com/twentyhq/core-team-issues/issues/975
Discussed with Charles

## In case of conflicts
Until this is approved I won't rebased and handle conflict, just need to
drop two latest commits and re run the scripts etc

## Legacy
We decided not to handle the existing lint error occurrences and
programmatically ignored them through a disable next line rule comment

## Open question
We might wanna activate the
[no-explicit-any](https://typescript-eslint.io/rules/no-explicit-any/)
`ignoreRestArgs` for our use case ?
```
    ignoreRestArgs?: boolean;
```

---------

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
2025-05-15 16:26:38 +02:00

270 lines
8.1 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ObjectRecordNonDestructiveEvent } from 'src/engine/core-modules/event-emitter/types/object-record-non-destructive-event';
import { ObjectRecordBaseEvent } from 'src/engine/core-modules/event-emitter/types/object-record.base.event';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { TimelineActivityRepository } from 'src/modules/timeline/repositiories/timeline-activity.repository';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
type TimelineActivity = Omit<ObjectRecordNonDestructiveEvent, 'properties'> & {
name: string;
objectName?: string;
linkedRecordCachedName?: string;
linkedRecordId?: string;
linkedObjectMetadataId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
properties: Record<string, any>; // more relaxed conditions than for internal events
};
@Injectable()
export class TimelineActivityService {
constructor(
@InjectObjectMetadataRepository(TimelineActivityWorkspaceEntity)
private readonly timelineActivityRepository: TimelineActivityRepository,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
private targetObjects: Record<string, string> = {
note: 'noteTarget',
task: 'taskTarget',
};
async upsertEvent({
event,
eventName,
workspaceId,
}: {
event: ObjectRecordBaseEvent;
eventName: string;
workspaceId: string;
}) {
const timelineActivities = await this.transformEventToTimelineActivities({
event,
workspaceId,
eventName,
});
if (!timelineActivities || timelineActivities.length === 0) return;
for (const timelineActivity of timelineActivities) {
await this.timelineActivityRepository.upsertOne(
timelineActivity.name,
timelineActivity.properties,
timelineActivity.objectName ?? event.objectMetadata.nameSingular,
timelineActivity.recordId,
workspaceId,
timelineActivity.workspaceMemberId,
timelineActivity.linkedRecordCachedName,
timelineActivity.linkedRecordId,
timelineActivity.linkedObjectMetadataId,
);
}
}
private async transformEventToTimelineActivities({
event,
workspaceId,
eventName,
}: {
event: ObjectRecordBaseEvent;
workspaceId: string;
eventName: string;
}): Promise<TimelineActivity[] | undefined> {
if (['note', 'task'].includes(event.objectMetadata.nameSingular)) {
const linkedTimelineActivities = await this.getLinkedTimelineActivities({
event,
workspaceId,
eventName,
});
// 2 timelines, one for the linked object and one for the task/note
if (linkedTimelineActivities && linkedTimelineActivities?.length > 0)
return [
...linkedTimelineActivities,
{ ...event, name: eventName },
] satisfies TimelineActivity[];
}
if (
['noteTarget', 'taskTarget', 'messageParticipant'].includes(
event.objectMetadata.nameSingular,
)
) {
return await this.getLinkedTimelineActivities({
event,
workspaceId,
eventName,
});
}
return [{ ...event, name: eventName }] satisfies TimelineActivity[];
}
private async getLinkedTimelineActivities({
event,
workspaceId,
eventName,
}: {
event: ObjectRecordBaseEvent;
workspaceId: string;
eventName: string;
}): Promise<TimelineActivity[] | undefined> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
switch (event.objectMetadata.nameSingular) {
case 'noteTarget':
return this.computeActivityTargets({
event,
dataSourceSchema,
activityType: 'note',
eventName,
workspaceId,
});
case 'taskTarget':
return this.computeActivityTargets({
event,
dataSourceSchema,
activityType: 'task',
eventName,
workspaceId,
});
case 'note':
case 'task':
return this.computeActivities({
event,
dataSourceSchema,
activityType: event.objectMetadata.nameSingular,
eventName,
workspaceId,
});
default:
return [];
}
}
private async computeActivities({
event,
dataSourceSchema,
activityType,
eventName,
workspaceId,
}: {
event: ObjectRecordBaseEvent;
dataSourceSchema: string;
activityType: string;
eventName: string;
workspaceId: string;
}) {
const activityTargets =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."${this.targetObjects[activityType]}"
WHERE "${activityType}Id" = $1`,
[event.recordId],
workspaceId,
);
const activity = await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."${activityType}"
WHERE "id" = $1`,
[event.recordId],
workspaceId,
);
if (activityTargets.length === 0) return;
if (activity.length === 0) return;
return activityTargets
.map((activityTarget) => {
const targetColumn: string[] = Object.entries(activityTarget)
.map(([columnName, columnValue]: [string, string]) => {
if (
columnName === activityType + 'Id' ||
!columnName.endsWith('Id')
)
return;
if (columnValue === null) return;
return columnName;
})
.filter((column): column is string => column !== undefined);
if (targetColumn.length === 0) return;
return {
...event,
name: 'linked-' + eventName,
objectName: targetColumn[0].replace(/Id$/, ''),
recordId: activityTarget[targetColumn[0]],
linkedRecordCachedName: activity[0].title,
linkedRecordId: activity[0].id,
linkedObjectMetadataId: event.objectMetadata.id,
} satisfies TimelineActivity;
})
.filter((event): event is TimelineActivity => event !== undefined);
}
private async computeActivityTargets({
event,
dataSourceSchema,
activityType,
eventName,
workspaceId,
}: {
event: ObjectRecordBaseEvent;
dataSourceSchema: string;
activityType: string;
eventName: string;
workspaceId: string;
}): Promise<TimelineActivity[] | undefined> {
const activityTarget =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."${this.targetObjects[activityType]}"
WHERE "id" = $1`,
[event.recordId],
workspaceId,
);
if (activityTarget.length === 0) return;
const activity = await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."${activityType}"
WHERE "id" = $1`,
[activityTarget[0].activityId],
workspaceId,
);
if (activity.length === 0) return;
const activityObjectMetadataId = event.objectMetadata.fields.find(
(field) => field.name === activityType,
)?.toRelationMetadata?.fromObjectMetadataId;
const targetColumn: string[] = Object.entries(activityTarget[0])
.map(([columnName, columnValue]: [string, string]) => {
if (columnName === activityType + 'Id' || !columnName.endsWith('Id'))
return;
if (columnValue === null) return;
return columnName;
})
.filter((column): column is string => column !== undefined);
if (targetColumn.length === 0) return;
return [
{
...event,
name: 'linked-' + eventName,
properties: {},
objectName: targetColumn[0].replace(/Id$/, ''),
recordId: activityTarget[0][targetColumn[0]],
linkedRecordCachedName: activity[0].title,
linkedRecordId: activity[0].id,
linkedObjectMetadataId: activityObjectMetadataId,
} satisfies TimelineActivity,
];
}
}