Improve seeds for timeline activities (#12692)

Keep improving seeds, this time add timeline activities
This commit is contained in:
Félix Malfait
2025-06-18 23:12:22 +02:00
committed by GitHub
parent 1bae411e58
commit a3d163f5e5
3 changed files with 468 additions and 0 deletions

View File

@ -94,6 +94,7 @@ import {
WORKSPACE_MEMBER_DATA_SEED_COLUMNS,
WORKSPACE_MEMBER_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { prefillViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-views';
import { prefillWorkspaceFavorites } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workspace-favorites';
@ -220,6 +221,7 @@ export class DevSeederDataService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly timelineActivitySeederService: TimelineActivitySeederService,
) {}
public async seed({
@ -251,6 +253,12 @@ export class DevSeederDataService {
});
}
await this.timelineActivitySeederService.seedTimelineActivities({
entityManager,
schemaName,
workspaceId,
});
const viewDefinitionsWithId = await prefillViews(
entityManager,
schemaName,

View File

@ -0,0 +1,458 @@
import { Injectable } from '@nestjs/common';
import chunk from 'lodash.chunk';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { COMPANY_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/company-data-seeds.constant';
import { NOTE_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/note-data-seeds.constant';
import { NOTE_TARGET_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/note-target-data-seeds.constant';
import { OPPORTUNITY_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/opportunity-data-seeds.constant';
import { PERSON_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/person-data-seeds.constant';
import { TASK_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/task-data-seeds.constant';
import { TASK_TARGET_DATA_SEEDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/task-target-data-seeds.constant';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
// Seeding-specific type based on TimelineActivityWorkspaceEntity for raw data insertion
type TimelineActivitySeedData = Pick<
TimelineActivityWorkspaceEntity,
| 'id'
| 'name'
| 'linkedRecordCachedName'
| 'linkedRecordId'
| 'linkedObjectMetadataId'
| 'workspaceMemberId'
| 'companyId'
| 'personId'
| 'noteId'
| 'taskId'
| 'opportunityId'
> & {
properties: string; // JSON stringified for raw insertion
createdAt: string; // ISO string for raw insertion
updatedAt: string; // ISO string for raw insertion
happensAt: string; // ISO string for raw insertion
};
type ActivityTargetInfo = {
targetType: string;
targetId: string;
};
type CreateTimelineActivityParams = {
entityType: string;
recordSeed: Record<string, unknown>;
index: number;
};
type CreateLinkedActivityParams = {
activityType: 'note' | 'task';
recordSeed: Record<string, unknown>;
index: number;
activityIndex: number;
linkedObjectMetadataId: string;
targetInfo: ActivityTargetInfo;
};
@Injectable()
export class TimelineActivitySeederService {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
async seedTimelineActivities({
entityManager,
schemaName,
workspaceId,
}: {
entityManager: WorkspaceEntityManager;
schemaName: string;
workspaceId: string;
}) {
const timelineActivities: TimelineActivitySeedData[] = [];
const { noteMetadataId, taskMetadataId } =
await this.getObjectMetadataIds(workspaceId);
let activityIndex = 0;
const entityConfigs = [
{ type: 'company', seeds: COMPANY_DATA_SEEDS },
{ type: 'person', seeds: PERSON_DATA_SEEDS },
{ type: 'note', seeds: NOTE_DATA_SEEDS },
{ type: 'task', seeds: TASK_DATA_SEEDS },
{ type: 'opportunity', seeds: OPPORTUNITY_DATA_SEEDS },
];
entityConfigs.forEach(({ type, seeds }) => {
seeds.forEach((seed, index) => {
const activity = this.createTimelineActivity({
entityType: type,
recordSeed: seed,
index,
});
timelineActivities.push(activity);
activityIndex++;
// For notes and tasks, create additional linked timeline activities on target objects
if (type === 'note' || type === 'task') {
const linkedObjectMetadataId =
type === 'note' ? noteMetadataId : taskMetadataId;
const linkedActivities = this.computeLinkedTimelineActivityRecords({
activityType: type,
recordSeed: seed,
index,
activityIndex,
linkedObjectMetadataId,
});
timelineActivities.push(...linkedActivities);
activityIndex += linkedActivities.length;
}
});
});
if (timelineActivities.length > 0) {
const batchSize = 100;
const timelineActivityBatches = chunk(timelineActivities, batchSize);
for (const batch of timelineActivityBatches) {
await entityManager
.createQueryBuilder(undefined, undefined, undefined, {
shouldBypassPermissionChecks: true,
})
.insert()
.into(`${schemaName}.timelineActivity`, [
'id',
'name',
'properties',
'linkedRecordCachedName',
'linkedRecordId',
'linkedObjectMetadataId',
'workspaceMemberId',
'companyId',
'personId',
'noteId',
'taskId',
'opportunityId',
'createdAt',
'updatedAt',
'happensAt',
])
.orIgnore()
.values(batch)
.execute();
}
}
}
private createTimelineActivity({
entityType,
recordSeed,
index,
}: CreateTimelineActivityParams): TimelineActivitySeedData {
const generateTimelineActivityId = (type: string, idx: number): string => {
const prefix = '20202020';
const entityCodes: Record<string, string> = {
company: '0001',
person: '0201',
note: '0601',
task: '0651',
opportunity: '0851',
};
const code = entityCodes[type] || '0000';
const paddedIndex = String(idx).padStart(4, '0');
return `${prefix}-${code}-4000-8001-${paddedIndex}00000001`;
};
const creationDate = new Date().toISOString();
const timelineActivity: TimelineActivitySeedData = {
id: generateTimelineActivityId(entityType, index + 1),
name: `${entityType}.created`,
properties: JSON.stringify({
after: this.getEventAfterRecordProperties({
type: entityType,
recordSeed,
}),
}),
linkedRecordCachedName: '',
linkedRecordId: recordSeed.id as string,
linkedObjectMetadataId: null,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
companyId: null,
personId: null,
noteId: null,
taskId: null,
opportunityId: null,
createdAt: creationDate,
updatedAt: creationDate,
happensAt: creationDate,
};
// @ts-expect-error - This is okay for morph
// alternative is to be explicit but that makes
timelineActivity[`${entityType}Id`] = recordSeed.id;
return timelineActivity;
}
private getEventAfterRecordProperties({
type,
recordSeed,
}: {
type: string;
recordSeed: Record<string, unknown>;
}): Record<string, unknown> {
const commonProperties = {
id: recordSeed.id,
};
switch (type) {
case 'company':
return {
...commonProperties,
name: recordSeed.name,
domainName: recordSeed.domainNamePrimaryLinkUrl,
employees: recordSeed.employees,
city: recordSeed.addressAddressCity,
};
case 'person':
return {
...commonProperties,
name: {
firstName: recordSeed.nameFirstName,
lastName: recordSeed.nameLastName,
},
email: recordSeed.emailsPrimaryEmail,
jobTitle: recordSeed.jobTitle,
};
case 'note':
return {
...commonProperties,
title: recordSeed.title,
body: recordSeed.body,
};
case 'task':
return {
...commonProperties,
title: recordSeed.title,
body: recordSeed.body,
status: recordSeed.status,
dueAt: recordSeed.dueAt,
};
case 'opportunity':
return {
...commonProperties,
name: recordSeed.name,
amount: recordSeed.amountAmountMicros,
stage: recordSeed.stage,
closeDate: recordSeed.closeDate,
};
default:
return commonProperties;
}
}
private computeLinkedTimelineActivityRecords({
activityType,
recordSeed,
index,
activityIndex,
linkedObjectMetadataId,
}: {
activityType: 'note' | 'task';
recordSeed: Record<string, unknown>;
index: number;
activityIndex: number;
linkedObjectMetadataId: string;
}): TimelineActivitySeedData[] {
const targetInfo = this.getActivityTargetInfo({
activityType,
recordSeed,
});
if (!targetInfo) {
return [];
}
const linkedActivity = this.computeLinkedActivityRecord({
activityType,
recordSeed,
index,
activityIndex,
linkedObjectMetadataId,
targetInfo,
});
return [linkedActivity];
}
private getActivityTargetInfo({
activityType,
recordSeed,
}: {
activityType: 'note' | 'task';
recordSeed: Record<string, unknown>;
}): ActivityTargetInfo | null {
if (activityType === 'note') {
const noteTargetSeed = NOTE_TARGET_DATA_SEEDS.find(
(target) => target.noteId === recordSeed.id,
);
if (!noteTargetSeed) {
return null;
}
if (noteTargetSeed.personId) {
return {
targetType: 'person',
targetId: noteTargetSeed.personId,
};
}
if (noteTargetSeed.companyId) {
return {
targetType: 'company',
targetId: noteTargetSeed.companyId,
};
}
if (noteTargetSeed.opportunityId) {
return {
targetType: 'opportunity',
targetId: noteTargetSeed.opportunityId,
};
}
}
if (activityType === 'task') {
const taskTargetSeed = TASK_TARGET_DATA_SEEDS.find(
(target) => target.taskId === recordSeed.id,
);
if (!taskTargetSeed) {
return null;
}
if (taskTargetSeed.personId) {
return {
targetType: 'person',
targetId: taskTargetSeed.personId,
};
}
if (taskTargetSeed.companyId) {
return {
targetType: 'company',
targetId: taskTargetSeed.companyId,
};
}
if (taskTargetSeed.opportunityId) {
return {
targetType: 'opportunity',
targetId: taskTargetSeed.opportunityId,
};
}
}
return null;
}
private computeLinkedActivityRecord({
activityType,
recordSeed,
index,
activityIndex,
linkedObjectMetadataId,
targetInfo,
}: CreateLinkedActivityParams): TimelineActivitySeedData {
const generateLinkedTimelineActivityId = (
type: string,
targetType: string,
idx: number,
): string => {
const prefix = '20202020';
const entityCodes: Record<string, string> = {
note: '0601',
task: '0651',
};
const targetCodes: Record<string, string> = {
person: '1001',
company: '2001',
opportunity: '3001',
};
const entityCode = entityCodes[type] || '0000';
const targetCode = targetCodes[targetType] || '0000';
const paddedIndex = idx.toString().padStart(4, '0');
return `${prefix}-${entityCode}-${targetCode}-8001-${paddedIndex}00000001`;
};
const creationDate = new Date().toISOString();
const linkedActivity: TimelineActivitySeedData = {
id: generateLinkedTimelineActivityId(
activityType,
targetInfo.targetType,
activityIndex,
),
name: `linked-${activityType}.created`,
properties: JSON.stringify({
after: {
id: recordSeed.id,
title: recordSeed.title,
body: recordSeed.body,
},
}),
linkedRecordCachedName:
(recordSeed.title as string) || `${activityType} ${index + 1}`,
linkedRecordId: recordSeed.id as string,
linkedObjectMetadataId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
companyId: null,
personId: null,
noteId: null,
taskId: null,
opportunityId: null,
createdAt: creationDate,
updatedAt: creationDate,
happensAt: creationDate,
};
// @ts-expect-error - This is okay for morph
// alternative is to be explicit but it's very verbose
linkedActivity[`${targetInfo.targetType}Id`] = targetInfo.targetId;
// @ts-expect-error - This is okay for morph
linkedActivity[`${activityType}Id`] = recordSeed.id;
return linkedActivity;
}
private async getObjectMetadataIds(workspaceId: string): Promise<{
noteMetadataId: string;
taskMetadataId: string;
}> {
const noteMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: 'note' },
});
const taskMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { nameSingular: 'task' },
});
if (!noteMetadata || !taskMetadata) {
throw new Error('Could not find note or task metadata');
}
return {
noteMetadataId: noteMetadata.id,
taskMetadataId: taskMetadata.id,
};
}
}

View File

@ -13,6 +13,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { DevSeederPermissionsService } from 'src/engine/workspace-manager/dev-seeder/core/services/dev-seeder-permissions.service';
import { DevSeederDataService } from 'src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service';
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { DevSeederMetadataService } from 'src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service';
import { DevSeederService } from 'src/engine/workspace-manager/dev-seeder/services/dev-seeder.service';
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
@ -37,6 +38,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
DevSeederMetadataService,
DevSeederPermissionsService,
DevSeederDataService,
TimelineActivitySeederService,
],
})
export class DevSeederModule {}