Sync metadata generate migrations (#2864)

* Sync Metadata generates migrations

* add execute migrations

* fix relations + add isActive on creation

* fix composite fields migration

* remove dependency

* use new metadata setup for seed-dev

* fix rebase

* remove unused code

* fix viewField dev seeds

* fix isSystem
This commit is contained in:
Weiko
2023-12-07 19:22:34 +01:00
committed by GitHub
parent 590912b30f
commit 5efc2f00b9
66 changed files with 2393 additions and 2943 deletions

View File

@ -0,0 +1,46 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service';
// TODO: implement dry-run
interface RunWorkspaceMigrationsOptions {
workspaceId: string;
}
@Command({
name: 'workspace:sync-metadata',
description: 'Sync metadata',
})
export class SyncWorkspaceMetadataCommand extends CommandRunner {
constructor(
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
private readonly dataSourceService: DataSourceService,
) {
super();
}
async run(
_passedParam: string[],
options: RunWorkspaceMigrationsOptions,
): Promise<void> {
// TODO: run in a dedicated job + run queries in a transaction.
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
options.workspaceId,
);
await this.workspaceSyncMetadataService.syncStandardObjectsAndFieldsMetadata(
dataSourceMetadata.id,
options.workspaceId,
);
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description: 'workspace id',
required: true,
})
parseWorkspaceId(value: string): string {
return value;
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { WorkspaceSyncMetadataModule } from 'src/workspace/workspace-sync-metadata/worksapce-sync-metadata.module';
import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command';
@Module({
imports: [WorkspaceSyncMetadataModule, DataSourceModule],
providers: [SyncWorkspaceMetadataCommand],
})
export class WorkspaceSyncMetadataCommandsModule {}

View File

@ -0,0 +1,183 @@
import camelCase from 'lodash.camelcase';
import 'reflect-metadata';
import { FieldMetadataDefaultValue } from 'src/metadata/field-metadata/interfaces/field-metadata-default-value.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateTargetColumnMap } from 'src/metadata/field-metadata/utils/generate-target-column-map.util';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import { generateDefaultValue } from 'src/metadata/field-metadata/utils/generate-default-value';
export type FieldMetadataDecorator = {
type: FieldMetadataType;
label: string;
description?: string | null;
icon?: string | null;
defaultValue?: FieldMetadataDefaultValue | null;
joinColumn?: string;
};
export type ObjectMetadataDecorator = {
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string | null;
icon?: string | null;
};
export type RelationMetadataDecorator = {
type: RelationMetadataType;
objectName: string;
inverseSideFieldName?: string;
};
function convertClassNameToObjectMetadataName(name: string): string {
const classSuffix = 'ObjectMetadata';
let objectName = camelCase(name);
if (objectName.endsWith(classSuffix)) {
objectName = objectName.slice(0, -classSuffix.length);
}
return objectName;
}
export function ObjectMetadata(
metadata: ObjectMetadataDecorator,
): ClassDecorator {
return (target) => {
const isSystem = Reflect.getMetadata('isSystem', target) || false;
const objectName = convertClassNameToObjectMetadataName(target.name);
Reflect.defineMetadata(
'objectMetadata',
{
nameSingular: objectName,
...metadata,
targetTableName: objectName,
isSystem,
isCustom: false,
description: metadata.description ?? null,
icon: metadata.icon ?? null,
},
target,
);
};
}
export function IsNullable() {
return function (target: object, fieldKey: string) {
Reflect.defineMetadata('isNullable', true, target, fieldKey);
};
}
export function IsSystem() {
return function (target: object, fieldKey?: string) {
if (fieldKey) {
Reflect.defineMetadata('isSystem', true, target, fieldKey);
} else {
Reflect.defineMetadata('isSystem', true, target);
}
};
}
export function FieldMetadata(
metadata: FieldMetadataDecorator,
): PropertyDecorator {
return (target: object, fieldKey: string) => {
const existingFieldMetadata =
Reflect.getMetadata('fieldMetadata', target.constructor) || {};
const isNullable =
Reflect.getMetadata('isNullable', target, fieldKey) || false;
const isSystem = Reflect.getMetadata('isSystem', target, fieldKey) || false;
const { joinColumn, ...fieldMetadata } = metadata;
Reflect.defineMetadata(
'fieldMetadata',
{
...existingFieldMetadata,
[fieldKey]: generateFieldMetadata(
fieldMetadata,
fieldKey,
isNullable,
isSystem,
),
...(joinColumn && fieldMetadata.type === FieldMetadataType.RELATION
? {
[joinColumn]: generateFieldMetadata(
{
...fieldMetadata,
type: FieldMetadataType.UUID,
label: `${fieldMetadata.label} id (foreign key)`,
description: `${fieldMetadata.description} id foreign key`,
defaultValue: null,
},
joinColumn,
isNullable,
true,
),
}
: {}),
},
target.constructor,
);
};
}
function generateFieldMetadata(
metadata: FieldMetadataDecorator,
fieldKey: string,
isNullable: boolean,
isSystem: boolean,
) {
const targetColumnMap = JSON.stringify(
generateTargetColumnMap(metadata.type, false, fieldKey),
);
const defaultValue =
metadata.defaultValue ?? generateDefaultValue(metadata.type);
return {
name: fieldKey,
...metadata,
targetColumnMap: targetColumnMap,
isNullable,
isSystem,
isCustom: false,
options: null, // TODO: handle options + stringify for the diff.
description: metadata.description ?? null,
icon: metadata.icon ?? null,
defaultValue: defaultValue ? JSON.stringify(defaultValue) : null,
};
}
export function RelationMetadata(
metadata: RelationMetadataDecorator,
): PropertyDecorator {
return (target: object, fieldKey: string) => {
const existingRelationMetadata =
Reflect.getMetadata('relationMetadata', target.constructor) || [];
const objectName = convertClassNameToObjectMetadataName(
target.constructor.name,
);
Reflect.defineMetadata(
'relationMetadata',
[
...existingRelationMetadata,
{
type: metadata.type,
fromObjectNameSingular: objectName,
toObjectNameSingular: metadata.objectName,
fromFieldMetadataName: fieldKey,
toFieldMetadataName: metadata.inverseSideFieldName ?? objectName,
},
],
target.constructor,
);
};
}

View File

@ -0,0 +1,47 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsSystem,
IsNullable,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'activityTargets',
labelSingular: 'Activity Target',
labelPlural: 'Activity Targets',
description: 'An activity target',
icon: 'IconCheckbox',
})
@IsSystem()
export class ActivityTargetObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Activity',
description: 'ActivityTarget activity',
icon: 'IconNotes',
joinColumn: 'activityId',
})
activity: object;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Person',
description: 'ActivityTarget person',
icon: 'IconUser',
joinColumn: 'personId',
})
@IsNullable()
person: object;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Company',
description: 'ActivityTarget company',
icon: 'IconBuildingSkyscraper',
joinColumn: 'companyId',
})
@IsNullable()
company: object;
}

View File

@ -0,0 +1,128 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
IsSystem,
IsNullable,
FieldMetadata,
RelationMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'activities',
labelSingular: 'Activity',
labelPlural: 'Activities',
description: 'An activity',
icon: 'IconCheckbox',
})
@IsSystem()
export class ActivityObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Title',
description: 'Activity title',
icon: 'IconNotes',
})
@IsNullable()
title: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Body',
description: 'Activity body',
icon: 'IconList',
})
@IsNullable()
body: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Type',
description: 'Activity type',
icon: 'IconCheckbox',
defaultValue: { value: 'Note' },
})
type: string;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Reminder Date',
description: 'Activity reminder date',
icon: 'IconCalendarEvent',
})
@IsNullable()
reminderAt: Date;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Due Date',
description: 'Activity due date',
icon: 'IconCalendarEvent',
})
@IsNullable()
dueAt: Date;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Completion Date',
description: 'Activity completion date',
icon: 'IconCheck',
})
@IsNullable()
completedAt: Date;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Targets',
description: 'Activity targets',
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'activityTarget',
})
activityTargets: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Attachments',
description: 'Activity attachments',
icon: 'IconFileImport',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'attachment',
})
attachments: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Comments',
description: 'Activity comments',
icon: 'IconComment',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'comment',
})
comments: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Author',
description: 'Activity author',
icon: 'IconUserCircle',
joinColumn: 'authorId',
})
author: object;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Assignee',
description: 'Acitivity assignee',
icon: 'IconUserCircle',
joinColumn: 'assigneeId',
})
assignee: object;
}

View File

@ -0,0 +1,44 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsNullable,
IsSystem,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'apiKeys',
labelSingular: 'Api Key',
labelPlural: 'Api Keys',
description: 'An api key',
icon: 'IconRobot',
})
@IsSystem()
export class ApiKeyObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'ApiKey name',
icon: 'IconLink',
defaultValue: { value: '' },
})
name: string;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Expiration date',
description: 'ApiKey expiration date',
icon: 'IconCalendar',
})
expiresAt: Date;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Revocation date',
description: 'ApiKey revocation date',
icon: 'IconCalendar',
})
@IsNullable()
revokedAt?: Date;
}

View File

@ -0,0 +1,80 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
IsNullable,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'attachments',
labelSingular: 'Attachment',
labelPlural: 'Attachments',
description: 'An attachment',
icon: 'IconFileImport',
})
@IsSystem()
export class AttachmentObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'Attachment name',
icon: 'IconFileUpload',
})
name: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Full path',
description: 'Attachment full path',
icon: 'IconLink',
})
fullPath: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Type',
description: 'Attachment type',
icon: 'IconList',
})
type: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Author',
description: 'Attachment author',
icon: 'IconCircleUser',
joinColumn: 'authorId',
})
author: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Activity',
description: 'Attachment activity',
icon: 'IconNotes',
joinColumn: 'activityId',
})
activity: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Person',
description: 'Attachment person',
icon: 'IconUser',
joinColumn: 'personId',
})
@IsNullable()
person: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Company',
description: 'Attachment company',
icon: 'IconBuildingSkyscraper',
joinColumn: 'companyId',
})
@IsNullable()
company: string;
}

View File

@ -0,0 +1,36 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
FieldMetadata,
IsSystem,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
export abstract class BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'Id',
icon: null,
description: null,
defaultValue: { type: 'uuid' },
})
@IsSystem()
id: string;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Creation date',
description: null,
icon: 'IconCalendar',
defaultValue: { type: 'now' },
})
createdAt: Date;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Update date',
description: null,
icon: 'IconCalendar',
defaultValue: { type: 'now' },
})
@IsSystem()
updatedAt: Date;
}

View File

@ -0,0 +1,44 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'comments',
labelSingular: 'Comment',
labelPlural: 'Comments',
description: 'A comment',
icon: 'IconMessageCircle',
})
@IsSystem()
export class CommentObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Body',
description: 'Comment body',
icon: 'IconLink',
defaultValue: { value: '' },
})
body: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Author',
description: 'Comment author',
icon: 'IconCircleUser',
joinColumn: 'authorId',
})
author: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Activity',
description: 'Comment activity',
icon: 'IconNotes',
joinColumn: 'activityId',
})
activity: string;
}

View File

@ -0,0 +1,164 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsNullable,
RelationMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: 'A company',
icon: 'IconBuildingSkyscraper',
})
export class CompanyObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'The company name',
icon: 'IconBuildingSkyscraper',
})
name: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Domain Name',
description:
'The company website URL. We use this url to fetch the company icon',
icon: 'IconLink',
})
@IsNullable()
domainName?: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Address',
description: 'The company address',
icon: 'IconMap',
})
@IsNullable()
address: string;
@FieldMetadata({
type: FieldMetadataType.NUMBER,
label: 'Employees',
description: 'Number of employees in the company',
icon: 'IconUsers',
})
@IsNullable()
employees: number;
@FieldMetadata({
type: FieldMetadataType.LINK,
label: 'Linkedin',
description: 'The company Linkedin account',
icon: 'IconBrandLinkedin',
})
@IsNullable()
linkedinLink: string;
@FieldMetadata({
type: FieldMetadataType.LINK,
label: 'X',
description: 'The company Twitter/X account',
icon: 'IconBrandX',
})
@IsNullable()
xLink: string;
@FieldMetadata({
type: FieldMetadataType.CURRENCY,
label: 'ARR',
description:
'Annual Recurring Revenue: The actual or estimated annual revenue of the company',
icon: 'IconMoneybag',
})
@IsNullable()
annualRecurringRevenue: number;
@FieldMetadata({
type: FieldMetadataType.BOOLEAN,
label: 'ICP',
description:
'Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you',
icon: 'IconTarget',
})
@IsNullable()
idealCustomerProfile: boolean;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'People',
description: 'People linked to the company.',
icon: 'IconUsers',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'person',
})
people: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Account Owner',
description:
'Your team member responsible for managing the company account',
icon: 'IconUserCircle',
joinColumn: 'accountOwnerId',
})
@IsNullable()
accountOwner: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Activities',
description: 'Activities tied to the company',
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'activityTarget',
})
activityTargets: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Opportunities',
description: 'Opportunities linked to the company.',
icon: 'IconTargetArrow',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'opportunity',
})
opportunities: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Favorites',
description: 'Favorites linked to the company',
icon: 'IconHeart',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'favorite',
})
favorites: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Attachments',
description: 'Attachments linked to the company.',
icon: 'IconFileImport',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'attachment',
})
attachments: object[];
}

View File

@ -0,0 +1,54 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'favorites',
labelSingular: 'Favorite',
labelPlural: 'Favorites',
description: 'A favorite',
icon: 'IconHeart',
})
@IsSystem()
export class FavoriteObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.NUMBER,
label: 'Position',
description: 'Favorite position',
icon: 'IconList',
defaultValue: { value: 0 },
})
position: number;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Workspace Member',
description: 'Favorite workspace member',
icon: 'IconCircleUser',
joinColumn: 'workspaceMemberId',
})
workspaceMember: object;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Person',
description: 'Favorite person',
icon: 'IconUser',
joinColumn: 'personId',
})
person: object;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Company',
description: 'Favorite company',
icon: 'IconBuildingSkyscraper',
joinColumn: 'companyId',
})
company: object;
}

View File

@ -0,0 +1,35 @@
import { ActivityTargetObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/activity-target.object-metadata';
import { ActivityObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/activity.object-metadata';
import { ApiKeyObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/api-key.object-metadata';
import { AttachmentObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/attachment.object-metadata';
import { CommentObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/comment.object-metadata';
import { CompanyObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/company.object-metadata';
import { FavoriteObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/favorite.object-metadata';
import { OpportunityObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/opportunity.object-metadata';
import { PersonObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/person.object-metadata';
import { PipelineStepObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/pipeline-step.object-metadata';
import { ViewFieldObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/view-field.object-metadata';
import { ViewFilterObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/view-filter.object-metadata';
import { ViewSortObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/view-sort.object-metadata';
import { ViewObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/view.object-metadata';
import { WebhookObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/webook.object-metadata';
import { WorkspaceMemberObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/workspace-member.object-metadata';
export const standardObjectMetadata = [
ActivityTargetObjectMetadata,
ActivityObjectMetadata,
ApiKeyObjectMetadata,
AttachmentObjectMetadata,
CommentObjectMetadata,
CompanyObjectMetadata,
FavoriteObjectMetadata,
OpportunityObjectMetadata,
PersonObjectMetadata,
PipelineStepObjectMetadata,
ViewFieldObjectMetadata,
ViewFilterObjectMetadata,
ViewSortObjectMetadata,
ViewObjectMetadata,
WebhookObjectMetadata,
WorkspaceMemberObjectMetadata,
];

View File

@ -0,0 +1,85 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
IsNullable,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'opportunities',
labelSingular: 'Opportunity',
labelPlural: 'Opportunities',
description: 'An opportunity',
icon: 'IconTargetArrow',
})
export class OpportunityObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.CURRENCY,
label: 'Amount',
description: 'Opportunity amount',
icon: 'IconCurrencyDollar',
})
@IsNullable()
amount: string;
@FieldMetadata({
type: FieldMetadataType.DATE_TIME,
label: 'Close date',
description: 'Opportunity close date',
icon: 'IconCalendarEvent',
})
@IsNullable()
closeDate: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Probability',
description: 'Opportunity probability',
icon: 'IconProgressCheck',
defaultValue: { value: '0' },
})
@IsNullable()
probability: string;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Pipeline Step',
description: 'Opportunity pipeline step',
icon: 'IconKanban',
joinColumn: 'pipelineStepId',
})
@IsNullable()
pipelineStep: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Point of Contact',
description: 'Opportunity point of contact',
icon: 'IconUser',
joinColumn: 'pointOfContactId',
})
@IsNullable()
pointOfContact: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Person',
description: 'Opportunity person',
icon: 'IconUser',
joinColumn: 'personId',
})
person: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Company',
description: 'Opportunity company',
icon: 'IconBuildingSkyscraper',
joinColumn: 'companyId',
})
@IsNullable()
company: string;
}

View File

@ -0,0 +1,164 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsNullable,
RelationMetadata,
IsSystem,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
icon: 'IconUser',
})
export class PersonObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.FULL_NAME,
label: 'Name',
description: 'Contacts name',
icon: 'IconUser',
})
@IsNullable()
name: string;
@FieldMetadata({
type: FieldMetadataType.EMAIL,
label: 'Email',
description: 'Contacts Email',
icon: 'IconMail',
})
@IsNullable()
email: string;
@FieldMetadata({
type: FieldMetadataType.LINK,
label: 'Linkedin',
description: 'Contacts Linkedin account',
icon: 'IconBrandLinkedin',
})
@IsNullable()
linkedinLink: string;
@FieldMetadata({
type: FieldMetadataType.LINK,
label: 'X',
description: 'Contacts X/Twitter account',
icon: 'IconBrandX',
})
@IsNullable()
xLink: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Job Title',
description: 'Contacts job title',
icon: 'IconBriefcase',
})
@IsNullable()
jobTitle: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Phone',
description: 'Contacts phone number',
icon: 'IconPhone',
})
@IsNullable()
phone: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'City',
description: 'Contacts city',
icon: 'IconMap',
})
@IsNullable()
city: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Avatar',
description: 'Contacts avatar',
icon: 'IconFileUpload',
})
@IsSystem()
@IsNullable()
avatarUrl: string;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Company',
description: 'Contacts company',
icon: 'IconBuildingSkyscraper',
joinColumn: 'companyId',
})
@IsNullable()
company: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'POC for Opportunities',
description: 'Point of Contact for Opportunities',
icon: 'IconTargetArrow',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'opportunity',
inverseSideFieldName: 'pointOfContact',
})
pointOfContactForOpportunities: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Activities',
description: 'Activities tied to the contact',
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'activityTarget',
})
activityTargets: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Opportunities',
description: 'Opportunities linked to the contact.',
icon: 'IconTargetArrow',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'opportunity',
})
opportunities: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Favorites',
description: 'Favorites linked to the contact',
icon: 'IconHeart',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'favorite',
})
favorites: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Attachments',
description: 'Attachments linked to the contact.',
icon: 'IconFileImport',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'attachment',
})
attachments: object[];
}

View File

@ -0,0 +1,62 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsNullable,
IsSystem,
RelationMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'pipelineSteps',
labelSingular: 'Pipeline Step',
labelPlural: 'Pipeline Steps',
description: 'A pipeline step',
icon: 'IconLayoutKanban',
})
@IsSystem()
export class PipelineStepObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'Pipeline Step name',
icon: 'IconCurrencyDollar',
})
@IsNullable()
name: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Color',
description: 'Pipeline Step color',
icon: 'IconColorSwatch',
})
@IsNullable()
color: string;
@FieldMetadata({
type: FieldMetadataType.NUMBER,
label: 'Position',
description: 'Pipeline Step position',
icon: 'IconHierarchy2',
defaultValue: { value: 0 },
})
@IsNullable()
position: number;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Opportunities',
description: 'Opportunities linked to the step.',
icon: 'IconTargetArrow',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'opportunity',
})
@IsNullable()
opportunities: object[];
}

View File

@ -0,0 +1,63 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
IsNullable,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'viewFields',
labelSingular: 'View Field',
labelPlural: 'View Fields',
description: '(System) View Fields',
icon: 'IconTag',
})
@IsSystem()
export class ViewFieldObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'Field Metadata Id',
description: 'View Field target field',
icon: 'IconTag',
})
fieldMetadataId: string;
@FieldMetadata({
type: FieldMetadataType.BOOLEAN,
label: 'Visible',
description: 'View Field visibility',
icon: 'IconEye',
defaultValue: { value: true },
})
isVisible: boolean;
@FieldMetadata({
type: FieldMetadataType.NUMBER,
label: 'Size',
description: 'View Field size',
icon: 'IconEye',
defaultValue: { value: 0 },
})
size: number;
@FieldMetadata({
type: FieldMetadataType.NUMBER,
label: 'Position',
description: 'View Field position',
icon: 'IconList',
defaultValue: { value: 0 },
})
position: number;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View',
description: 'View Field related view',
icon: 'IconLayoutCollage',
joinColumn: 'viewId',
})
@IsNullable()
view?: object;
}

View File

@ -0,0 +1,63 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
IsNullable,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'viewFilters',
labelSingular: 'View Filter',
labelPlural: 'View Filters',
description: '(System) View Filters',
icon: 'IconFilterBolt',
})
@IsSystem()
export class ViewFilterObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'Field Metadata Id',
description: 'View Filter target field',
icon: null,
})
fieldMetadataId: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Operand',
description: 'View Filter operand',
icon: null,
defaultValue: { value: 'Contains' },
})
operand: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Value',
description: 'View Filter value',
icon: null,
defaultValue: { value: '' },
})
value: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Display Value',
description: 'View Filter Display Value',
icon: null,
defaultValue: { value: '' },
})
displayValue: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View',
description: 'View Filter related view',
icon: 'IconLayoutCollage',
joinColumn: 'viewId',
})
@IsNullable()
view: string;
}

View File

@ -0,0 +1,45 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
FieldMetadata,
IsNullable,
IsSystem,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'viewSorts',
labelSingular: 'View Sort',
labelPlural: 'View Sorts',
description: '(System) View Sorts',
icon: 'IconArrowsSort',
})
@IsSystem()
export class ViewSortObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'Field Metadata Id',
description: 'View Sort target field',
icon: 'IconTag',
})
fieldMetadataId: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Direction',
description: 'View Sort direction',
icon: null,
defaultValue: { value: 'asc' },
})
direction: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View',
description: 'View Sort related view',
icon: 'IconLayoutCollage',
joinColumn: 'viewId',
})
@IsNullable()
view: string;
}

View File

@ -0,0 +1,81 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
RelationMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'views',
labelSingular: 'View',
labelPlural: 'Views',
description: '(System) Views',
icon: 'IconLayoutCollage',
})
@IsSystem()
export class ViewObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Name',
description: 'View name',
icon: null,
defaultValue: { value: '' },
})
name: string;
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'Object Metadata Id',
description: 'View target object',
icon: null,
})
objectMetadataId: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Type',
description: 'View type',
icon: null,
defaultValue: { value: 'table' },
})
type: string;
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View Fields',
description: 'View Fields',
icon: 'IconTag',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'viewField',
})
viewFields: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View Filters',
description: 'View Filters',
icon: 'IconFilterBolt',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'viewFilter',
})
viewFilters: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'View Sorts',
description: 'View Sorts',
icon: 'IconArrowsSort',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'viewSort',
})
viewSorts: object[];
}

View File

@ -0,0 +1,35 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'webhooks',
labelSingular: 'Webhook',
labelPlural: 'Webhooks',
description: 'A webhook',
icon: 'IconRobot',
})
@IsSystem()
export class WebhookObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Target Url',
description: 'Webhook target url',
icon: 'IconLink',
defaultValue: { value: '' },
})
targetUrl: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Operation',
description: 'Webhook operation',
icon: 'IconCheckbox',
defaultValue: { value: '' },
})
operation: string;
}

View File

@ -0,0 +1,142 @@
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
ObjectMetadata,
IsSystem,
FieldMetadata,
IsNullable,
RelationMetadata,
} from 'src/workspace/workspace-sync-metadata/decorators/metadata.decorator';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
@ObjectMetadata({
namePlural: 'workspaceMembers',
labelSingular: 'Workspace Member',
labelPlural: 'Workspace Members',
description: 'A workspace member',
icon: 'IconUserCircle',
})
@IsSystem()
export class WorkspaceMemberObjectMetadata extends BaseObjectMetadata {
@FieldMetadata({
type: FieldMetadataType.FULL_NAME,
label: 'Name',
description: 'Workspace member name',
icon: 'IconCircleUser',
})
name: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Color Scheme',
description: 'Preferred color scheme',
icon: 'IconColorSwatch',
defaultValue: { value: 'Light' },
})
colorScheme: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Language',
description: 'Preferred language',
icon: 'IconLanguage',
defaultValue: { value: 'en' },
})
locale: string;
@FieldMetadata({
type: FieldMetadataType.TEXT,
label: 'Avatar Url',
description: 'Workspace member avatar',
icon: 'IconFileUpload',
defaultValue: { value: '' },
})
@IsNullable()
avatarUrl: string;
@FieldMetadata({
type: FieldMetadataType.UUID,
label: 'User Id',
description: 'Associated User Id',
icon: 'IconCircleUsers',
})
userId: string;
// Relations
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Authored activities',
description: 'Activities created by the workspace member',
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'activity',
inverseSideFieldName: 'author',
})
authoredActivities: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Assigned activities',
description: 'Activities assigned to the workspace member',
icon: 'IconCheckbox',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'activity',
inverseSideFieldName: 'assignee',
})
assignedActivities: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Favorites',
description: 'Favorites linked to the workspace member',
icon: 'IconHeart',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'favorite',
})
favorites: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Account Owner For Companies',
description: 'Account owner for companies',
icon: 'IconBriefcase',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'company',
inverseSideFieldName: 'accountOwner',
})
accountOwnerForCompanies: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Authored attachments',
description: 'Attachments created by the workspace member',
icon: 'IconFileImport',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'attachment',
inverseSideFieldName: 'author',
})
authoredAttachments: object[];
@FieldMetadata({
type: FieldMetadataType.RELATION,
label: 'Authored comments',
description: 'Authored comments',
icon: 'IconComment',
})
@RelationMetadata({
type: RelationMetadataType.ONE_TO_MANY,
objectName: 'comment',
inverseSideFieldName: 'author',
})
authoredComments: object[];
}

View File

@ -0,0 +1,109 @@
import assert from 'assert';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { BaseObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/base.object-metadata';
export class MetadataParser {
static parseMetadata(
metadata: typeof BaseObjectMetadata,
workspaceId: string,
dataSourceId: string,
) {
const objectMetadata = Reflect.getMetadata('objectMetadata', metadata);
const fieldMetadata = Reflect.getMetadata('fieldMetadata', metadata);
if (objectMetadata) {
const fields = Object.values(fieldMetadata);
return {
...objectMetadata,
workspaceId,
dataSourceId,
fields: fields.map((field: FieldMetadataEntity) => ({
...field,
workspaceId,
isSystem: objectMetadata.isSystem || field.isSystem,
defaultValue: field.defaultValue || null,
options: field.options || null,
})),
};
}
return undefined;
}
static parseAllMetadata(
metadata: (typeof BaseObjectMetadata)[],
workspaceId: string,
dataSourceId: string,
) {
return metadata.map((_metadata) =>
MetadataParser.parseMetadata(_metadata, workspaceId, dataSourceId),
);
}
static parseRelationMetadata(
metadata: typeof BaseObjectMetadata,
workspaceId: string,
objectMetadataFromDB: Record<string, ObjectMetadataEntity>,
) {
const objectMetadata = Reflect.getMetadata('objectMetadata', metadata);
const relationMetadata = Reflect.getMetadata('relationMetadata', metadata);
if (!relationMetadata) return [];
return relationMetadata.map((relation) => {
const fromObjectMetadata =
objectMetadataFromDB[relation.fromObjectNameSingular];
assert(
fromObjectMetadata,
`Object ${relation.fromObjectNameSingular} not found in DB
for relation defined in class ${objectMetadata.nameSingular}`,
);
const toObjectMetadata =
objectMetadataFromDB[relation.toObjectNameSingular];
assert(
toObjectMetadata,
`Object ${relation.toObjectNameSingular} not found in DB
for relation defined in class ${objectMetadata.nameSingular}`,
);
const fromFieldMetadata =
fromObjectMetadata?.fields[relation.fromFieldMetadataName];
assert(
fromFieldMetadata,
`Field ${relation.fromFieldMetadataName} not found in object ${relation.fromObjectNameSingular}
for relation defined in class ${objectMetadata.nameSingular}`,
);
const toFieldMetadata =
toObjectMetadata?.fields[relation.toFieldMetadataName];
assert(
toFieldMetadata,
`Field ${relation.toFieldMetadataName} not found in object ${relation.toObjectNameSingular}
for relation defined in class ${objectMetadata.nameSingular}`,
);
return {
relationType: relation.type,
fromObjectMetadataId: fromObjectMetadata?.id,
toObjectMetadataId: toObjectMetadata?.id,
fromFieldMetadataId: fromFieldMetadata?.id,
toFieldMetadataId: toFieldMetadata?.id,
workspaceId,
};
});
}
static parseAllRelations(
metadata: (typeof BaseObjectMetadata)[],
workspaceId: string,
objectMetadataFromDB: Record<string, ObjectMetadataEntity>,
) {
return metadata.flatMap((_metadata) =>
MetadataParser.parseRelationMetadata(
_metadata,
workspaceId,
objectMetadataFromDB,
),
);
}
}

View File

@ -0,0 +1,101 @@
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import {
filterIgnoredProperties,
mapObjectMetadataByUniqueIdentifier,
} from './sync-metadata.util';
describe('filterIgnoredProperties', () => {
it('should filter out properties based on the ignore list', () => {
const obj = {
name: 'John',
age: 30,
email: 'john@example.com',
address: '123 Main St',
};
const propertiesToIgnore = ['age', 'address'];
const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore);
expect(filteredObj).toEqual({
name: 'John',
email: 'john@example.com',
});
});
it('should return the original object if ignore list is empty', () => {
const obj = {
name: 'John',
age: 30,
email: 'john@example.com',
address: '123 Main St',
};
const propertiesToIgnore: string[] = [];
const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore);
expect(filteredObj).toEqual(obj);
});
it('should return an empty object if the original object is empty', () => {
const obj = {};
const propertiesToIgnore = ['age', 'address'];
const filteredObj = filterIgnoredProperties(obj, propertiesToIgnore);
expect(filteredObj).toEqual({});
});
});
describe('mapObjectMetadataByUniqueIdentifier', () => {
it('should convert an array of ObjectMetadataEntity objects into a map', () => {
const arr: DeepPartial<ObjectMetadataEntity>[] = [
{
nameSingular: 'user',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
],
},
{
nameSingular: 'product',
fields: [
{ name: 'id', type: FieldMetadataType.UUID },
{ name: 'name', type: FieldMetadataType.TEXT },
{ name: 'price', type: FieldMetadataType.UUID },
],
},
];
const mappedObject = mapObjectMetadataByUniqueIdentifier(
arr as ObjectMetadataEntity[],
);
expect(mappedObject).toEqual({
user: {
nameSingular: 'user',
fields: {
id: { name: 'id', type: FieldMetadataType.UUID },
name: { name: 'name', type: FieldMetadataType.TEXT },
},
},
product: {
nameSingular: 'product',
fields: {
id: { name: 'id', type: FieldMetadataType.UUID },
name: { name: 'name', type: FieldMetadataType.TEXT },
price: { name: 'price', type: FieldMetadataType.UUID },
},
},
});
});
it('should return an empty map if the input array is empty', () => {
const arr: ObjectMetadataEntity[] = [];
const mappedObject = mapObjectMetadataByUniqueIdentifier(arr);
expect(mappedObject).toEqual({});
});
});

View File

@ -0,0 +1,65 @@
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
/**
* This utility function filters out properties from an object based on a list of properties to ignore.
* It returns a new object with only the properties that are not in the ignore list.
*
* @param obj - The object to filter.
* @param propertiesToIgnore - An array of property names to ignore.
* @returns A new object with filtered properties.
*/
export const filterIgnoredProperties = (
obj: any,
propertiesToIgnore: string[],
mapFunction?: (value: any) => any,
) => {
return Object.fromEntries(
Object.entries(obj)
.filter(([key]) => !propertiesToIgnore.includes(key))
.map(([key, value]) => [key, mapFunction ? mapFunction(value) : value]),
);
};
/**
* This utility function converts an array of ObjectMetadataEntity objects into a map,
* where the keys are the nameSingular properties of the objects.
* Each object in the map contains the original object metadata and its fields as a nested map.
*
* @param arr - The array of ObjectMetadataEntity objects to convert.
* @returns A map of object metadata, with nameSingular as the key and the object as the value.
*/
export const mapObjectMetadataByUniqueIdentifier = (
arr: ObjectMetadataEntity[],
) => {
return arr.reduce((acc, curr) => {
acc[curr.nameSingular] = {
...curr,
fields: curr.fields.reduce((acc, curr) => {
acc[curr.name] = curr;
return acc;
}, {}),
};
return acc;
}, {});
};
export const convertStringifiedFieldsToJSON = (fieldMetadata) => {
if (fieldMetadata.targetColumnMap) {
fieldMetadata.targetColumnMap = JSON.parse(
fieldMetadata.targetColumnMap as unknown as string,
);
}
if (fieldMetadata.defaultValue) {
fieldMetadata.defaultValue = JSON.parse(
fieldMetadata.defaultValue as unknown as string,
);
}
if (fieldMetadata.options) {
fieldMetadata.options = JSON.parse(
fieldMetadata.options as unknown as string,
);
}
return fieldMetadata;
};

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FieldMetadataEntity } from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/metadata/relation-metadata/relation-metadata.entity';
import { WorkspaceMigrationEntity } from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationModule } from 'src/metadata/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceSyncMetadataService } from 'src/workspace/workspace-sync-metadata/workspace-sync.metadata.service';
@Module({
imports: [
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
TypeOrmModule.forFeature(
[
FieldMetadataEntity,
ObjectMetadataEntity,
RelationMetadataEntity,
WorkspaceMigrationEntity,
],
'metadata',
),
],
exports: [WorkspaceSyncMetadataService],
providers: [WorkspaceSyncMetadataService],
})
export class WorkspaceSyncMetadataModule {}

View File

@ -0,0 +1,435 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import diff from 'microdiff';
import { Repository } from 'typeorm';
import camelCase from 'lodash.camelcase';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/metadata/object-metadata/object-metadata.entity';
import {
RelationMetadataEntity,
RelationMetadataType,
} from 'src/metadata/relation-metadata/relation-metadata.entity';
import { MetadataParser } from 'src/workspace/workspace-sync-metadata/utils/metadata.parser';
import {
mapObjectMetadataByUniqueIdentifier,
filterIgnoredProperties,
convertStringifiedFieldsToJSON,
} from 'src/workspace/workspace-sync-metadata/utils/sync-metadata.util';
import { standardObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnRelation,
WorkspaceMigrationEntity,
WorkspaceMigrationTableAction,
} from 'src/metadata/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migration-runner/workspace-migration-runner.service';
@Injectable()
export class WorkspaceSyncMetadataService {
constructor(
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(RelationMetadataEntity, 'metadata')
private readonly relationMetadataRepository: Repository<RelationMetadataEntity>,
@InjectRepository(WorkspaceMigrationEntity, 'metadata')
private readonly workspaceMigrationRepository: Repository<WorkspaceMigrationEntity>,
) {}
/**
*
* Sync all standard objects and fields metadata for a given workspace and data source
* This will update the metadata if it has changed and generate migrations based on the diff.
*
* @param dataSourceId
* @param workspaceId
*/
public async syncStandardObjectsAndFieldsMetadata(
dataSourceId: string,
workspaceId: string,
) {
const standardObjects = MetadataParser.parseAllMetadata(
standardObjectMetadata,
workspaceId,
dataSourceId,
);
try {
const objectsInDB = await this.objectMetadataRepository.find({
where: { workspaceId, dataSourceId, isCustom: false },
relations: ['fields'],
});
const objectsInDBByName =
mapObjectMetadataByUniqueIdentifier(objectsInDB);
const standardObjectsByName =
mapObjectMetadataByUniqueIdentifier(standardObjects);
const objectsToCreate: ObjectMetadataEntity[] = [];
const objectsToDelete = objectsInDB.filter(
(objectInDB) => !standardObjectsByName[objectInDB.nameSingular],
);
const objectsToUpdate: Record<string, ObjectMetadataEntity> = {};
const fieldsToCreate: FieldMetadataEntity[] = [];
const fieldsToDelete: FieldMetadataEntity[] = [];
const fieldsToUpdate: Record<string, FieldMetadataEntity> = {};
for (const standardObjectName in standardObjectsByName) {
const standardObject = standardObjectsByName[standardObjectName];
const objectInDB = objectsInDBByName[standardObjectName];
if (!objectInDB) {
objectsToCreate.push(standardObject);
continue;
}
// Deconstruct fields and compare objects and fields independently
const { fields: objectInDBFields, ...objectInDBWithoutFields } =
objectInDB;
const { fields: standardObjectFields, ...standardObjectWithoutFields } =
standardObject;
const objectPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'labelIdentifierFieldMetadataId',
'imageIdentifierFieldMetadataId',
'isActive',
];
const objectDiffWithoutIgnoredProperties = filterIgnoredProperties(
objectInDBWithoutFields,
objectPropertiesToIgnore,
);
const fieldPropertiesToIgnore = [
'id',
'createdAt',
'updatedAt',
'objectMetadataId',
'isActive',
];
const objectInDBFieldsWithoutDefaultFields = Object.fromEntries(
Object.entries(objectInDBFields).map(([key, value]) => {
if (value === null || typeof value !== 'object') {
return [key, value];
}
return [
key,
filterIgnoredProperties(
value,
fieldPropertiesToIgnore,
(property) => {
if (property !== null && typeof property === 'object') {
return JSON.stringify(property);
}
return property;
},
),
];
}),
);
// Compare objects
const objectDiff = diff(
objectDiffWithoutIgnoredProperties,
standardObjectWithoutFields,
);
// Compare fields
const fieldsDiff = diff(
objectInDBFieldsWithoutDefaultFields,
standardObjectFields,
);
for (const diff of objectDiff) {
// We only handle CHANGE here as REMOVE and CREATE are handled earlier.
if (diff.type === 'CHANGE') {
const property = diff.path[0];
objectsToUpdate[objectInDB.id] = {
...objectsToUpdate[objectInDB.id],
[property]: diff.value,
};
}
}
for (const diff of fieldsDiff) {
const fieldName = diff.path[0];
if (diff.type === 'CREATE')
fieldsToCreate.push({
...standardObjectFields[fieldName],
objectMetadataId: objectInDB.id,
});
if (diff.type === 'REMOVE' && diff.path.length === 1)
fieldsToDelete.push(objectInDBFields[fieldName]);
if (diff.type === 'CHANGE') {
const property = diff.path[diff.path.length - 1];
fieldsToUpdate[objectInDBFields[fieldName].id] = {
...fieldsToUpdate[objectInDBFields[fieldName].id],
[property]: diff.value,
};
}
}
}
// CREATE OBJECTS
await this.objectMetadataRepository.save(
objectsToCreate.map((object) => ({
...object,
isActive: true,
fields: Object.values(object.fields).map((field) => ({
...convertStringifiedFieldsToJSON(field),
isActive: true,
})),
})),
);
// UPDATE OBJECTS, this is not optimal as we are running n queries here.
for (const [key, value] of Object.entries(objectsToUpdate)) {
await this.objectMetadataRepository.update(key, value);
}
// DELETE OBJECTS
if (objectsToDelete.length > 0) {
await this.objectMetadataRepository.delete(
objectsToDelete.map((object) => object.id),
);
}
// CREATE FIELDS
await this.fieldMetadataRepository.save(
fieldsToCreate.map((field) => convertStringifiedFieldsToJSON(field)),
);
// UPDATE FIELDS
for (const [key, value] of Object.entries(fieldsToUpdate)) {
await this.fieldMetadataRepository.update(
key,
convertStringifiedFieldsToJSON(value),
);
}
// DELETE FIELDS
// TODO: handle relation fields deletion. We need to delete the relation metadata first due to the DB constraint.
const fieldsToDeleteWithoutRelationType = fieldsToDelete.filter(
(field) => field.type !== FieldMetadataType.RELATION,
);
if (fieldsToDeleteWithoutRelationType.length > 0) {
await this.fieldMetadataRepository.delete(
fieldsToDeleteWithoutRelationType.map((field) => field.id),
);
}
// Generate migrations
await this.generateMigrationsFromSync(
objectsToCreate,
objectsToDelete,
fieldsToCreate,
fieldsToDelete,
);
// We run syncRelationMetadata after everything to ensure that all objects and fields are
// in the DB before creating relations.
await this.syncRelationMetadata(workspaceId, dataSourceId);
// Execute migrations
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
} catch (error) {
console.error('Sync of standard objects failed with:', error);
}
}
private async syncRelationMetadata(
workspaceId: string,
dataSourceId: string,
) {
const objectsInDB = await this.objectMetadataRepository.find({
where: { workspaceId, dataSourceId, isCustom: false },
relations: ['fields'],
});
const objectsInDBByName = mapObjectMetadataByUniqueIdentifier(objectsInDB);
const standardRelations = MetadataParser.parseAllRelations(
standardObjectMetadata,
workspaceId,
objectsInDBByName,
).reduce((result, currentObject) => {
const key = `${currentObject.fromObjectMetadataId}->${currentObject.fromFieldMetadataId}`;
result[key] = currentObject;
return result;
}, {});
// TODO: filter out custom relations once isCustom has been added to relationMetadata table
const relationsInDB = await this.relationMetadataRepository.find({
where: { workspaceId },
});
// We filter out 'id' later because we need it to remove the relation from DB
const relationsInDBWithoutIgnoredProperties = relationsInDB
.map((relation) =>
filterIgnoredProperties(relation, ['createdAt', 'updatedAt']),
)
.reduce((result, currentObject) => {
const key = `${currentObject.fromObjectMetadataId}->${currentObject.fromFieldMetadataId}`;
result[key] = currentObject;
return result;
}, {});
// Compare relations
const relationsDiff = diff(
relationsInDBWithoutIgnoredProperties,
standardRelations,
);
const relationsToCreate: RelationMetadataEntity[] = [];
const relationsToDelete: RelationMetadataEntity[] = [];
for (const diff of relationsDiff) {
if (diff.type === 'CREATE') {
relationsToCreate.push(diff.value);
}
if (diff.type === 'REMOVE' && diff.path[diff.path.length - 1] !== 'id') {
relationsToDelete.push(diff.oldValue);
}
}
try {
// CREATE RELATIONS
await this.relationMetadataRepository.save(relationsToCreate);
// DELETE RELATIONS
if (relationsToDelete.length > 0) {
await this.relationMetadataRepository.delete(
relationsToDelete.map((relation) => relation.id),
);
}
await this.generateRelationMigrationsFromSync(
relationsToCreate,
relationsToDelete,
objectsInDB,
);
} catch (error) {
console.error('Sync of standard relations failed with:', error);
}
}
private async generateMigrationsFromSync(
objectsToCreate: ObjectMetadataEntity[],
_objectsToDelete: ObjectMetadataEntity[],
_fieldsToCreate: FieldMetadataEntity[],
_fieldsToDelete: FieldMetadataEntity[],
) {
const migrationsToSave: Partial<WorkspaceMigrationEntity>[] = [];
if (objectsToCreate.length > 0) {
objectsToCreate.map((object) => {
const migrations = [
{
name: object.targetTableName,
action: 'create',
} satisfies WorkspaceMigrationTableAction,
...Object.values(object.fields)
.filter((field) => field.type !== FieldMetadataType.RELATION)
.map(
(field) =>
({
name: object.targetTableName,
action: 'alter',
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.CREATE,
field,
),
} satisfies WorkspaceMigrationTableAction),
),
];
migrationsToSave.push({
workspaceId: object.workspaceId,
isCustom: false,
migrations,
});
});
}
await this.workspaceMigrationRepository.save(migrationsToSave);
// TODO: handle delete migrations
}
private async generateRelationMigrationsFromSync(
relationsToCreate: RelationMetadataEntity[],
_relationsToDelete: RelationMetadataEntity[],
objectsInDB: ObjectMetadataEntity[],
) {
const relationsMigrationsToSave: Partial<WorkspaceMigrationEntity>[] = [];
if (relationsToCreate.length > 0) {
relationsToCreate.map((relation) => {
const toObjectMetadata = objectsInDB.find(
(object) => object.id === relation.toObjectMetadataId,
);
const fromObjectMetadata = objectsInDB.find(
(object) => object.id === relation.fromObjectMetadataId,
);
if (!toObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${relation.toObjectMetadataId} not found`,
);
}
if (!fromObjectMetadata) {
throw new Error(
`ObjectMetadata with id ${relation.fromObjectMetadataId} not found`,
);
}
const toFieldMetadata = toObjectMetadata.fields.find(
(field) => field.id === relation.toFieldMetadataId,
);
if (!toFieldMetadata) {
throw new Error(
`FieldMetadata with id ${relation.toFieldMetadataId} not found`,
);
}
const migrations = [
{
name: toObjectMetadata.targetTableName,
action: 'alter',
columns: [
{
action: WorkspaceMigrationColumnActionType.RELATION,
columnName: `${camelCase(toFieldMetadata.name)}Id`,
referencedTableName: fromObjectMetadata.targetTableName,
referencedTableColumnName: 'id',
isUnique:
relation.relationType === RelationMetadataType.ONE_TO_ONE,
} satisfies WorkspaceMigrationColumnRelation,
],
} satisfies WorkspaceMigrationTableAction,
];
relationsMigrationsToSave.push({
workspaceId: relation.workspaceId,
isCustom: false,
migrations,
});
});
}
await this.workspaceMigrationRepository.save(relationsMigrationsToSave);
// TODO: handle delete migrations
}
}