Compare commits

...

8 Commits

Author SHA1 Message Date
Paul Rastoin 27c9fd9d19 Fix REST API filters (#12929)
# Introduction
close https://github.com/twentyhq/twenty/issues/12921

### Done here:
- Removed
[check-order-by.utils.ts](https://github.com/twentyhq/twenty/pull/12929/files#diff-d044effc0b77b3b67523595ce0febd786d3a0fd74ae905ce2efc349134d7c7d0)
that was a duplicated
- new debug entry `twenty-server` entrypoint
- fixed the fields name computation in case of a relation field metadata
type
- Updated and refactored coverage both unit and integration


![image](https://github.com/user-attachments/assets/e3f0937a-8b54-4ab5-8348-0cd742c107ea)
2025-07-02 11:55:07 +02:00
Weiko a8f0c90add fix server linter 2025-06-26 15:54:28 +02:00
Weiko 10eba9e36d Add fix schema array type command (#12887)
Following this fix https://github.com/twentyhq/twenty/pull/12874 we now
need to add a command to retroactively fix existing columns

---------

Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
2025-06-26 15:54:18 +02:00
Weiko 4b1fb10e53 Fix upgrade command updating version during dry run (#12909)
Fix upgrade command updating version during dry run
2025-06-26 15:54:06 +02:00
Paul Rastoin 6061184361 fix(server): incr metadata version (#12907)
Following https://github.com/twentyhq/twenty/pull/12883
2025-06-26 15:53:58 +02:00
Guillim 1b457cb515 escaping special chars for events (#12872)
Escaping newly discovered special chars for events

- \x00 is a Unicode for nul
- \x7f is a Unicode for delete

Edit:
i initially, just in case after looking at the logs, i removed the
single quotes as well (there are fobiden in the standard RFC 5545) but
after reflexion other props than icalUID rely on this sanitization so
leaving as such. It should already be taken care of anyway by typeorm


Ref sentry :
https://twenty-v7.sentry.io/issues/6567295627/?environment=prod&environment=prod-eu&project=4507072499810304&query=&referrer=issue-stream&stream_index=13

Fixes https://github.com/twentyhq/twenty/issues/12827
2025-06-26 15:53:48 +02:00
Paul Rastoin 0e8a6f73e3 Fix standard object computed metadata (#12883)
# Introduction
close https://github.com/twentyhq/twenty/issues/12879

This PR has a global impact all on workspaces
It should be crash tested in local using an anon extract of the db
2025-06-26 15:53:40 +02:00
Weiko 9f04ccb94a Fix array field migration (#12874)
Fixes https://github.com/twentyhq/twenty/issues/12726

## Context
Regression introduced in https://github.com/twentyhq/twenty/pull/12639
We now run raw queries for some migrations (column creations for
example) and we created a `typeormBuildCreateColumnSql` util for that.
The issue is that previously we were using typeorm methods which was
using isArray from the input to create $type[] (text[], number[])
properly which was not done in the new `typeormBuildCreateColumnSql`
util (so the type was text, number, etc...)
Edit: actually this was correctly implemented for Enum types (multi
select fields) but not Array type, I've updated the code accordingly
2025-06-25 17:05:55 +02:00
24 changed files with 1001 additions and 182 deletions
+21
View File
@@ -82,6 +82,27 @@
"env": {
"NODE_ENV": "test"
},
},
{
"type": "node",
"request": "launch",
"name": "twenty-server - debug unit test file (to launch with test file open)",
"runtimeExecutable": "npx",
"runtimeArgs": [
"nx",
"run",
"twenty-server:jest",
"--",
"--config",
"./jest.config.ts",
"${relativeFile}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test"
},
}
]
}
@@ -114,10 +114,13 @@ export abstract class UpgradeCommandRunner extends ActiveOrSuspendedWorkspacesMi
await this.syncWorkspaceMetadataCommand.runOnWorkspace(args);
await this.runAfterSyncMetadata(args);
await this.workspaceRepository.update(
{ id: workspaceId },
{ version: this.currentAppVersion.version },
);
if (!options.dryRun) {
await this.workspaceRepository.update(
{ id: workspaceId },
{ version: this.currentAppVersion.version },
);
}
this.logger.log(
chalk.blue(`Upgrade for workspace ${workspaceId} completed.`),
);
@@ -0,0 +1,102 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
@Command({
name: 'upgrade:1-1:fix-schema-array-type',
description: 'Fix columns for ARRAY fields to be text[] in the DB schema',
})
export class FixSchemaArrayTypeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly databaseStructureService: DatabaseStructureService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly typeORMService: TypeORMService,
@InjectRepository(FieldMetadataEntity, 'core')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(`Fixing ARRAY field columns for workspace ${workspaceId}`);
const arrayFields: FieldMetadataEntity[] =
await this.fieldMetadataRepository.find({
where: {
workspaceId,
type: FieldMetadataType.ARRAY,
isCustom: true,
},
relations: ['object'],
});
if (arrayFields.length === 0) {
this.logger.log('No ARRAY fields found for this workspace.');
return;
}
for (const field of arrayFields) {
const object = field.object;
const tableName = computeObjectTargetTable(object);
const schemaName =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const columns =
await this.databaseStructureService.getWorkspaceTableColumns(
schemaName,
tableName,
);
const columnName = computeColumnName(field);
const dbColumn = columns.find((col) => col.columnName === columnName);
if (!dbColumn) {
this.logger.warn(
`Column ${columnName} not found in table ${schemaName}.${tableName}`,
);
continue;
}
if (dbColumn.dataType === 'text[]' && dbColumn.isArray) {
continue;
}
this.logger.log(
`Altering column ${schemaName}.${tableName}.${columnName} to type text[] (was ${dbColumn.dataType})`,
);
if (!options.dryRun) {
const queryRunner = this.typeORMService
.getMainDataSource()
.createQueryRunner();
await queryRunner.connect();
try {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" TYPE text[] USING "${columnName}"::text[];`,
);
} finally {
await queryRunner.release();
}
}
}
}
}
@@ -0,0 +1,74 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { IsNull, Not, Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade:1-1:fix-update-standard-fields-is-label-synced-with-name',
description:
'Fix isLabelSyncedWithName property for standard fields to match actual label-name synchronization state',
})
export class FixUpdateStandardFieldsIsLabelSyncedWithName extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(FieldMetadataEntity, 'core')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(`Updating standard fields for workspace ${workspaceId}`);
const workspaceStandardFields = await this.fieldMetadataRepository.find({
where: {
workspaceId,
isCustom: false,
standardId: Not(IsNull()),
},
});
let updatedFields = 0;
for (const field of workspaceStandardFields) {
const isLabelSyncedWithName =
computeMetadataNameFromLabel(field.label) === field.name;
if (field.isLabelSyncedWithName === isLabelSyncedWithName) {
continue;
}
if (!options.dryRun) {
await this.fieldMetadataRepository.update(field.id, {
isLabelSyncedWithName,
});
}
updatedFields++;
this.logger.log(`Updated isLabelSyncedMetadata for field ${field.id}`);
}
if (!options.dryRun && updatedFields > 0) {
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
}
this.logger.log(
`Updated ${updatedFields} field.s for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command';
import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
@Module({
imports: [
TypeOrmModule.forFeature(
[
Workspace,
AppToken,
User,
UserWorkspace,
FieldMetadataEntity,
ObjectMetadataEntity,
],
'core',
),
WorkspaceDataSourceModule,
WorkspaceMigrationRunnerModule,
WorkspaceMetadataVersionModule,
WorkspaceHealthModule,
TypeORMModule,
],
providers: [
FixUpdateStandardFieldsIsLabelSyncedWithName,
FixSchemaArrayTypeCommand,
],
exports: [
FixUpdateStandardFieldsIsLabelSyncedWithName,
FixSchemaArrayTypeCommand,
],
})
export class V1_1_UpgradeVersionCommandModule {}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { V0_54_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-54/0-54-upgrade-version-command.module';
import { V0_55_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/0-55/0-55-upgrade-version-command.module';
import { V1_1_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-1/1-1-upgrade-version-command.module';
import {
DatabaseMigrationService,
UpgradeCommand,
@@ -15,6 +16,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
TypeOrmModule.forFeature([Workspace], 'core'),
V0_54_UpgradeVersionCommandModule,
V0_55_UpgradeVersionCommandModule,
V1_1_UpgradeVersionCommandModule,
WorkspaceSyncMetadataModule,
],
providers: [DatabaseMigrationService, UpgradeCommand],
@@ -21,6 +21,8 @@ import { FixStandardSelectFieldsPositionCommand } from 'src/database/commands/up
import { LowercaseUserAndInvitationEmailsCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-lowercase-user-and-invitation-emails.command';
import { MigrateDefaultAvatarUrlToUserWorkspaceCommand } from 'src/database/commands/upgrade-version-command/0-54/0-54-migrate-default-avatar-url-to-user-workspace.command';
import { DeduplicateIndexedFieldsCommand } from 'src/database/commands/upgrade-version-command/0-55/0-55-deduplicate-indexed-fields.command';
import { FixSchemaArrayTypeCommand } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-schema-array-type.command';
import { FixUpdateStandardFieldsIsLabelSyncedWithName } from 'src/database/commands/upgrade-version-command/1-1/1-1-fix-update-standard-field-is-label-synced-with-name.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -135,6 +137,10 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 0.55 Commands
protected readonly deduplicateIndexedFieldsCommand: DeduplicateIndexedFieldsCommand,
// 1.1 Commands
protected readonly fixSchemaArrayTypeCommand: FixSchemaArrayTypeCommand,
protected readonly fixUpdateStandardFieldsIsLabelSyncedWithNameCommand: FixUpdateStandardFieldsIsLabelSyncedWithName,
) {
super(
workspaceRepository,
@@ -175,12 +181,21 @@ export class UpgradeCommand extends UpgradeCommandRunner {
beforeSyncMetadata: [],
};
const commands_110: VersionCommands = {
beforeSyncMetadata: [
this.fixUpdateStandardFieldsIsLabelSyncedWithNameCommand,
this.fixSchemaArrayTypeCommand,
],
afterSyncMetadata: [],
};
this.allCommands = {
'0.53.0': commands_053,
'0.54.0': commands_054,
'0.55.0': commands_055,
'0.60.0': commands_060,
'1.0.0': commands_100,
'1.1.0': commands_110,
};
}
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`checkFields should accept valid relation field name 1`] = `"field 'pointOfContact' does not exist in 'opportunity' object"`;
exports[`checkFields should reject invalid field name 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
exports[`checkFields should reject mix of valid and invalid field names 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
@@ -1,76 +1,63 @@
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { EachTestingContext } from 'twenty-shared/testing';
import {
fieldNumberMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { OPPORTUNITY_WITH_FIELDS_MAPS } from 'src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
import { checkArrayFields } from 'src/engine/api/rest/core/query-builder/utils/check-order-by.utils';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
describe('checkFields', () => {
const completeFieldNumberMock: FieldMetadataInterface = {
id: 'field-number-id',
type: fieldNumberMock.type,
name: fieldNumberMock.name,
label: 'Field Number',
objectMetadataId: 'object-metadata-id',
isNullable: fieldNumberMock.isNullable,
defaultValue: fieldNumberMock.defaultValue,
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const fieldsById: FieldMetadataMap = {
'field-number-id': completeFieldNumberMock,
};
const mockObjectMetadataWithFieldMaps = {
...objectMetadataItemMock,
fieldsById,
fieldIdByName: {
[completeFieldNumberMock.name]: completeFieldNumberMock.id,
const testCases: EachTestingContext<{
fields: string[];
shouldThrow?: boolean;
}>[] = [
{
title: 'should accept valid join column id',
context: {
fields: ['pointOfContactId'],
},
},
fieldIdByJoinColumnName: {},
indexMetadatas: [],
};
{
title: 'should accept valid relation field name',
context: {
shouldThrow: true,
fields: ['pointOfContact'],
},
},
{
title: 'should accept valid field name',
context: {
fields: ['position'],
},
},
{
title: 'should reject invalid field name',
context: {
fields: ['wrongField'],
shouldThrow: true,
},
},
{
title: 'should reject mix of valid and invalid field names',
context: {
fields: ['position', 'wrongField'],
shouldThrow: true,
},
},
{
title: 'should accept composite field',
context: {
fields: ['source'],
},
},
];
it('should check field types', () => {
expect(() =>
checkFields(mockObjectMetadataWithFieldMaps, ['fieldNumber']),
).not.toThrow();
expect(() =>
checkFields(mockObjectMetadataWithFieldMaps, ['wrongField']),
).toThrow();
expect(() =>
checkFields(mockObjectMetadataWithFieldMaps, [
'fieldNumber',
'wrongField',
]),
).toThrow();
});
it('should check field types from array of fields', () => {
expect(() =>
checkArrayFields(mockObjectMetadataWithFieldMaps, [
{ fieldNumber: undefined },
]),
).not.toThrow();
expect(() =>
checkArrayFields(mockObjectMetadataWithFieldMaps, [
{ wrongField: undefined },
]),
).toThrow();
expect(() =>
checkArrayFields(mockObjectMetadataWithFieldMaps, [
{ fieldNumber: undefined },
{ wrongField: undefined },
]),
).toThrow();
it.each(testCases)('$title', ({ context }) => {
if (context.shouldThrow) {
expect(() =>
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
).toThrowErrorMatchingSnapshot();
} else {
expect(() =>
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
).not.toThrow();
}
});
});
@@ -0,0 +1,463 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export const OPPORTUNITY_WITH_FIELDS_MAPS = {
id: '20202020-6e2c-42f6-a83c-cc58d776af88',
nameSingular: 'opportunity',
namePlural: 'opportunities',
labelSingular: 'Opportunity',
labelPlural: 'Opportunities',
description: 'An opportunity',
icon: 'IconTargetArrow',
targetTableName: 'DEPRECATED',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
labelIdentifierFieldMetadataId: '20202020-c2f1-4435-adca-22931f8b41b6',
imageIdentifierFieldMetadataId: null,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
indexMetadatas: [], // unused
fieldsById: {
'20202020-c2f1-4435-adca-22931f8b41b6': {
id: '20202020-c2f1-4435-adca-22931f8b41b6',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
defaultValue: "''",
description: 'The opportunity name',
icon: 'IconTargetArrow',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-5eef-417a-b517-ebeedaa8e10b': {
id: '20202020-5eef-417a-b517-ebeedaa8e10b',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.CURRENCY,
name: 'amount',
label: 'Amount',
defaultValue: { amountMicros: null, currencyCode: "''" },
description: 'Opportunity amount',
icon: 'IconCurrencyDollar',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-597c-44d3-98ec-ea71aea5256b': {
id: '20202020-597c-44d3-98ec-ea71aea5256b',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.DATE_TIME,
name: 'closeDate',
label: 'Close date',
defaultValue: null,
description: 'Opportunity close date',
icon: 'IconCalendarEvent',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-9b94-454a-94ca-8afb09c68faf': {
id: '20202020-9b94-454a-94ca-8afb09c68faf',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.SELECT,
name: 'stage',
label: 'Stage',
defaultValue: "'NEW'",
description: 'Opportunity stage',
icon: 'IconProgressCheck',
options: [
{
id: '20202020-dba8-4975-81bd-b29a41c0a387',
color: 'red',
label: 'New',
value: 'NEW',
position: 0,
},
{
id: '20202020-1c9d-490c-940c-bf47addcd6a1',
color: 'purple',
label: 'Screening',
value: 'SCREENING',
position: 1,
},
{
id: '20202020-1368-438b-8702-6d5e5727a888',
color: 'sky',
label: 'Meeting',
value: 'MEETING',
position: 2,
},
{
id: '20202020-41e4-4b4e-a038-f02645f55767',
color: 'turquoise',
label: 'Proposal',
value: 'PROPOSAL',
position: 3,
},
{
id: '20202020-8acf-4934-8519-42f7c9133cd5',
color: 'yellow',
label: 'Customer',
value: 'CUSTOMER',
position: 4,
},
],
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-30a5-4d8e-9b93-12d31ece0aaa': {
id: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.POSITION,
name: 'position',
label: 'Position',
defaultValue: 0,
description: 'Opportunity record position',
icon: 'IconHierarchy2',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-f95f-424f-ab32-65961e8e9635': {
id: '20202020-f95f-424f-ab32-65961e8e9635',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.ACTOR,
name: 'createdBy',
label: 'Created by',
defaultValue: { name: "'System'", source: "'MANUAL'", context: {} },
description: 'The creator of the record',
icon: 'IconCreativeCommonsSa',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-5e10-4780-babb-38a465ac546c': {
id: '20202020-5e10-4780-babb-38a465ac546c',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.TEXT,
name: 'searchVector',
label: 'Search vector',
defaultValue: null,
description: 'Field used for full-text search',
icon: 'IconUser',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-8f4a-4f8d-822e-90fe72f75b79': {
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.UUID,
name: 'id',
label: 'Id',
defaultValue: 'uuid',
description: 'Id',
icon: 'Icon123',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-f120-4b59-b239-f7f1d8eb243e': {
id: '20202020-f120-4b59-b239-f7f1d8eb243e',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.DATE_TIME,
name: 'createdAt',
label: 'Creation date',
defaultValue: 'now',
description: 'Creation date',
icon: 'IconCalendar',
settings: { displayFormat: 'RELATIVE' } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: false,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-dcc8-4318-9756-b87377692561': {
id: '20202020-dcc8-4318-9756-b87377692561',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.DATE_TIME,
name: 'updatedAt',
label: 'Last update',
defaultValue: 'now',
description: 'Last time the record was changed',
icon: 'IconCalendarClock',
settings: { displayFormat: 'RELATIVE' } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: false,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-1694-4f8b-8760-61a5ff330022': {
id: '20202020-1694-4f8b-8760-61a5ff330022',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.DATE_TIME,
name: 'deletedAt',
label: 'Deleted at',
defaultValue: null,
description: 'Date when the record was deleted',
icon: 'IconCalendarMinus',
settings: { displayFormat: 'RELATIVE' } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-4f52-4dea-a116-723f9bf7f082': {
id: '20202020-4f52-4dea-a116-723f9bf7f082',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'pointOfContact',
label: 'Point of Contact',
defaultValue: null,
description: 'Opportunity point of contact',
icon: 'IconUser',
settings: {
onDelete: 'SET_NULL',
relationType: 'MANY_TO_ONE',
joinColumnName: 'pointOfContactId',
} as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
relationTargetFieldMetadataId: '20202020-a36b-4889-97d4-63a578423688',
relationTargetObjectMetadataId: '20202020-6799-4a38-92d3-8e844a7ea8ab',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-fc02-4be2-be1a-e121daf5400d': {
id: '20202020-fc02-4be2-be1a-e121daf5400d',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
defaultValue: null,
description: 'Opportunity company',
icon: 'IconBuildingSkyscraper',
settings: {
onDelete: 'SET_NULL',
relationType: 'MANY_TO_ONE',
joinColumnName: 'companyId',
} as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
relationTargetFieldMetadataId: '20202020-bd16-4f63-8165-0a7f5d78170d',
relationTargetObjectMetadataId: '20202020-0be8-4764-8e0d-7a2e1c66f78c',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-fd9f-48f0-bd5f-5b0fec6a5de4': {
id: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'favorites',
label: 'Favorites',
defaultValue: null,
description: 'Favorites linked to the opportunity',
icon: 'IconHeart',
settings: { relationType: RelationType.ONE_TO_MANY } as any,
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
relationTargetFieldMetadataId: '20202020-7c3c-4149-b400-5a958910c7a2',
relationTargetObjectMetadataId: '20202020-df10-42dc-baed-ea77db7ad96c',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-88ab-4138-98ce-80533bb423e3': {
id: '20202020-88ab-4138-98ce-80533bb423e3',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'taskTargets',
label: 'Tasks',
defaultValue: null,
description: 'Tasks tied to the opportunity',
icon: 'IconCheckbox',
settings: { relationType: RelationType.ONE_TO_MANY } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: false,
relationTargetFieldMetadataId: '20202020-eb77-4b1c-b6a6-d5dcd13b1634',
relationTargetObjectMetadataId: '20202020-3af1-4c4f-90f4-cd43c53f7f41',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-4258-422b-b35b-db3f090af8da': {
id: '20202020-4258-422b-b35b-db3f090af8da',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'noteTargets',
label: 'Notes',
defaultValue: null,
description: 'Notes tied to the opportunity',
icon: 'IconNotes',
settings: { relationType: RelationType.ONE_TO_MANY } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: false,
relationTargetFieldMetadataId: '20202020-d927-4c91-9893-28cea5aff979',
relationTargetObjectMetadataId: '20202020-8a5e-4dea-868b-71611e718a73',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-16ca-40a7-a1ba-712975c916cd': {
id: '20202020-16ca-40a7-a1ba-712975c916cd',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'attachments',
label: 'Attachments',
defaultValue: null,
description: 'Attachments linked to the opportunity',
icon: 'IconFileImport',
settings: { relationType: RelationType.ONE_TO_MANY } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
relationTargetFieldMetadataId: '20202020-9236-427a-8a8a-a2296b93b542',
relationTargetObjectMetadataId: '20202020-3005-4c93-a04c-2941f7424f54',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
'20202020-92a5-47bf-a38d-c1c72b2c3e4d': {
id: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
type: FieldMetadataType.RELATION,
name: 'timelineActivities',
label: 'Timeline Activities',
defaultValue: null,
description: 'Timeline Activities linked to the opportunity.',
icon: 'IconTimelineEvent',
settings: { relationType: RelationType.ONE_TO_MANY } as any,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
isUnique: false,
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
isLabelSyncedWithName: true,
relationTargetFieldMetadataId: '20202020-2d54-41c7-b886-29deca3c28d5',
relationTargetObjectMetadataId: '20202020-a89e-4e4d-b3d9-c3f99e7c7483',
createdAt: new Date('2025-06-27T12:55:13.271Z'),
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
},
},
fieldIdByName: {
name: '20202020-c2f1-4435-adca-22931f8b41b6',
amount: '20202020-5eef-417a-b517-ebeedaa8e10b',
closeDate: '20202020-597c-44d3-98ec-ea71aea5256b',
stage: '20202020-9b94-454a-94ca-8afb09c68faf',
position: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
createdBy: '20202020-f95f-424f-ab32-65961e8e9635',
searchVector: '20202020-5e10-4780-babb-38a465ac546c',
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
createdAt: '20202020-f120-4b59-b239-f7f1d8eb243e',
updatedAt: '20202020-dcc8-4318-9756-b87377692561',
deletedAt: '20202020-1694-4f8b-8760-61a5ff330022',
pointOfContact: '20202020-4f52-4dea-a116-723f9bf7f082',
company: '20202020-fc02-4be2-be1a-e121daf5400d',
favorites: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
taskTargets: '20202020-88ab-4138-98ce-80533bb423e3',
noteTargets: '20202020-4258-422b-b35b-db3f090af8da',
attachments: '20202020-16ca-40a7-a1ba-712975c916cd',
timelineActivities: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
},
fieldIdByJoinColumnName: {
pointOfContactId: '20202020-4f52-4dea-a116-723f9bf7f082',
companyId: '20202020-fc02-4be2-be1a-e121daf5400d',
},
} as const satisfies ObjectMetadataItemWithFieldMaps;
@@ -1,16 +1,22 @@
import { BadRequestException } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const checkFields = (
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
fieldNames: string[],
): void => {
const fieldMetadataNames = Object.values(objectMetadataItem.fieldsById)
.map((field) => {
const fieldMetadataNames: string[] = Object.values(
objectMetadataItemWithFieldsMaps.fieldsById,
)
.flatMap((field) => {
if (isCompositeFieldMetadataType(field.type)) {
const compositeType = compositeTypeDefinitions.get(field.type);
@@ -29,15 +35,19 @@ export const checkFields = (
].flat();
}
if (isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION)) {
return field.settings?.joinColumnName;
}
return field.name;
})
.flat();
.filter(isDefined);
for (const fieldName of fieldNames) {
if (!fieldMetadataNames.includes(fieldName)) {
throw new BadRequestException(
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
objectMetadataItem,
objectMetadataItemWithFieldsMaps,
)}' object`,
);
}
@@ -1,48 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
export const checkArrayFields = (
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
fields: Array<Partial<ObjectRecord>>,
): void => {
const fieldMetadataNames = Object.values(objectMetadataItem.fieldsById)
.map((field) => {
if (isCompositeFieldMetadataType(field.type)) {
const compositeType = compositeTypeDefinitions.get(field.type);
if (!compositeType) {
throw new BadRequestException(
`Composite type '${field.type}' not found`,
);
}
return [
field.name,
compositeType.properties.map(
(compositeProperty) => compositeProperty.name,
),
].flat();
}
return field.name;
})
.flat();
for (const fieldObj of fields) {
for (const fieldName in fieldObj) {
if (!fieldMetadataNames.includes(fieldName)) {
throw new BadRequestException(
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
objectMetadataItem,
)}' object`,
);
}
}
}
};
@@ -7,7 +7,7 @@ import {
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { checkArrayFields } from 'src/engine/api/rest/core/query-builder/utils/check-order-by.utils';
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
@@ -82,7 +82,10 @@ export class OrderByInputFactory {
result = [...result, ...resultFields];
}
checkArrayFields(objectMetadata.objectMetadataMapItem, result);
checkFields(
objectMetadata.objectMetadataMapItem,
result.flatMap((fields) => Object.keys(fields)),
);
return this.addDefaultOrderById(result);
}
@@ -6,7 +6,7 @@ import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { TypedReflect } from 'src/utils/typed-reflect';
@@ -19,12 +19,8 @@ export interface WorkspaceFieldOptions<
> {
standardId: string;
type: T;
label:
| MessageDescriptor
| ((objectMetadata: ObjectMetadataEntity) => MessageDescriptor);
description?:
| MessageDescriptor
| ((objectMetadata: ObjectMetadataEntity) => MessageDescriptor);
label: MessageDescriptor;
description?: MessageDescriptor;
icon?: string;
defaultValue?: FieldMetadataDefaultValue<T>;
options?: FieldMetadataOptions<T>;
@@ -76,30 +72,18 @@ export function WorkspaceField<T extends FieldMetadataType>(
const defaultValue = (options.defaultValue ??
generateDefaultValue(options.type)) as FieldMetadataDefaultValue | null;
const name = propertyKey.toString();
const label = options.label.message ?? '';
const isLabelSyncedWithName = computeMetadataNameFromLabel(label) === name;
metadataArgsStorage.addFields({
target: object.constructor,
standardId: options.standardId,
name: propertyKey.toString(),
label:
typeof options.label === 'function'
? (objectMetadata: ObjectMetadataEntity) =>
(
options.label as (
obj: ObjectMetadataEntity,
) => MessageDescriptor
)(objectMetadata).message ?? ''
: (options.label.message ?? ''),
name,
label,
type: options.type,
description:
typeof options.description === 'function'
? (objectMetadata: ObjectMetadataEntity) =>
(
options.description as (
obj: ObjectMetadataEntity,
) => MessageDescriptor
)(objectMetadata).message ?? ''
: (options.description?.message ?? ''),
isLabelSyncedWithName,
description: options.description?.message ?? '',
icon: options.icon,
defaultValue,
options: options.options,
@@ -5,14 +5,13 @@ import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metada
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { TypedReflect } from 'src/utils/typed-reflect';
interface WorkspaceRelationBaseOptions<TClass> {
standardId: string;
label:
| MessageDescriptor
| ((objectMetadata: ObjectMetadataEntity) => MessageDescriptor);
label: MessageDescriptor;
description?:
| MessageDescriptor
| ((objectMetadata: ObjectMetadataEntity) => MessageDescriptor);
@@ -64,20 +63,15 @@ export function WorkspaceRelation<TClass extends object>(
object,
propertyKey.toString(),
);
const name = propertyKey.toString();
const label = options.label.message ?? '';
const isLabelSyncedWithName = computeMetadataNameFromLabel(label) === name;
metadataArgsStorage.addRelations({
target: object.constructor,
standardId: options.standardId,
name: propertyKey.toString(),
label:
typeof options.label === 'function'
? (objectMetadata: ObjectMetadataEntity) =>
(
options.label as (
obj: ObjectMetadataEntity,
) => MessageDescriptor
)(objectMetadata).message ?? ''
: (options.label.message ?? ''),
name,
label,
type: options.type,
description:
typeof options.description === 'function'
@@ -96,6 +90,7 @@ export function WorkspaceRelation<TClass extends object>(
isNullable,
isSystem,
gate,
isLabelSyncedWithName,
});
};
}
@@ -105,4 +105,6 @@ export interface WorkspaceFieldMetadataArgs {
* Is active field.
*/
readonly asExpression?: string;
readonly isLabelSyncedWithName: boolean;
}
@@ -84,4 +84,6 @@ export interface WorkspaceRelationMetadataArgs {
* Is active field.
*/
readonly isActive?: boolean;
readonly isLabelSyncedWithName: boolean;
}
@@ -32,6 +32,6 @@ import { WorkspaceFixService } from './services/workspace-fix.service';
FieldMetadataHealthService,
WorkspaceFixService,
],
exports: [WorkspaceHealthService],
exports: [WorkspaceHealthService, DatabaseStructureService],
})
export class WorkspaceHealthModule {}
@@ -0,0 +1,95 @@
import { typeormBuildCreateColumnSql } from './typeorm-build-create-column-sql.util';
describe('typeormBuildCreateColumnSql', () => {
const table = { name: 'my_table' };
it('should generate SQL for a simple text column', () => {
const column = {
name: 'col1',
type: 'text',
isArray: false,
isNullable: false,
default: undefined,
generatedType: undefined,
asExpression: undefined,
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col1" text NOT NULL');
});
it('should generate SQL for a text[] (array) column', () => {
const column = {
name: 'col2',
type: 'text',
isArray: true,
isNullable: true,
default: undefined,
generatedType: undefined,
asExpression: undefined,
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col2" text[]');
});
it('should generate SQL for an enum column', () => {
const column = {
name: 'col3',
type: 'enum',
isArray: false,
isNullable: true,
default: undefined,
generatedType: undefined,
asExpression: undefined,
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col3" "my_table_col3_enum"');
});
it('should generate SQL for an enum[] (array) column', () => {
const column = {
name: 'col4',
type: 'enum',
isArray: true,
isNullable: false,
default: undefined,
generatedType: undefined,
asExpression: undefined,
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col4" "my_table_col4_enum"[] NOT NULL');
});
it('should include default and nullability', () => {
const column = {
name: 'col5',
type: 'text',
isArray: false,
isNullable: false,
default: "'default'",
generatedType: undefined,
asExpression: undefined,
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col5" text NOT NULL DEFAULT \'default\'');
});
it('should include generatedType and asExpression', () => {
const column = {
name: 'col6',
type: 'text',
isArray: false,
isNullable: true,
default: undefined,
generatedType: 'STORED' as const,
asExpression: 'lower(col6)',
};
const sql = typeormBuildCreateColumnSql({ table, column });
expect(sql).toBe('"col6" text GENERATED ALWAYS AS (lower(col6)) STORED');
});
});
@@ -28,11 +28,12 @@ export const typeormBuildCreateColumnSql = ({
tableName: table.name,
columnName: column.name,
})}"`;
if (column.isArray) columnSql += ' array';
} else {
columnSql += ' ' + column.type;
}
if (column.isArray) columnSql += '[]';
if (column.generatedType === 'STORED' && column.asExpression) {
columnSql += ` GENERATED ALWAYS AS (${column.asExpression}) STORED`;
}
@@ -121,7 +121,7 @@ export class StandardFieldFactory {
* Create field metadata
*/
private createFieldMetadata(
workspaceEntityMetadataArgs: WorkspaceEntityMetadataArgs | undefined,
_workspaceEntityMetadataArgs: WorkspaceEntityMetadataArgs | undefined,
workspaceFieldMetadataArgs: WorkspaceFieldMetadataArgs,
context: WorkspaceSyncContext,
): PartialFieldMetadata[] {
@@ -153,7 +153,7 @@ export class StandardFieldFactory {
isActive: workspaceFieldMetadataArgs.isActive ?? true,
asExpression: workspaceFieldMetadataArgs.asExpression,
generatedType: workspaceFieldMetadataArgs.generatedType,
isLabelSyncedWithName: true,
isLabelSyncedWithName: workspaceFieldMetadataArgs.isLabelSyncedWithName,
},
];
}
@@ -192,7 +192,8 @@ export class StandardFieldFactory {
isNullable: true,
isUnique: false,
isActive: workspaceRelationMetadataArgs.isActive ?? true,
isLabelSyncedWithName: true,
isLabelSyncedWithName:
workspaceRelationMetadataArgs.isLabelSyncedWithName,
});
return fieldMetadataCollection;
@@ -1,4 +1,5 @@
import { calendar_v3 as calendarV3 } from 'googleapis';
import { EachTestingContext } from 'twenty-shared/testing';
import { formatGoogleCalendarEvents } from 'src/modules/calendar/calendar-event-import-manager/drivers/google-calendar/utils/format-google-calendar-event.util';
import { CalendarEventParticipantResponseStatus } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
@@ -77,23 +78,48 @@ describe('formatGoogleCalendarEvents', () => {
);
});
it('should sanitize a UCALID with improper exit char 0x00', () => {
const testCases: EachTestingContext<{ input: string; expected: string }>[] = [
{
title: 'should sanitize a UCALID with \u0000',
context: {
input: '\u0000eventStrange@google.com',
expected: 'eventStrange@google.com',
},
},
{
title: 'should sanitize a UCALID with \u0000',
context: {
input: '>\u0000\u0015-;_^W&p\u001f',
expected: '>\u0015-;_^W&p\u001f',
},
},
{
title: 'should sanitize a UCALID with \x00',
context: {
input: '\u0002y\u0013\x00',
expected: '\u0002y\u0013',
},
},
{
title: 'should sanitize a UCALID with del',
context: {
input: 'del\u0002y\u0013',
expected: 'del\u0002y\u0013',
},
},
];
it.each(testCases)('$title', ({ context }) => {
const mockGoogleEventWithImproperUcalid: calendarV3.Schema$Event = {
...mockGoogleEvent,
iCalUID: '\u0000eventStrange@google.com',
};
const mockGoogleEventWithImproperUcalid2: calendarV3.Schema$Event = {
...mockGoogleEvent,
iCalUID: '>\u0000\u0015-;_^W&p\u001f',
iCalUID: context.input,
};
const result = formatGoogleCalendarEvents([
mockGoogleEventWithImproperUcalid,
mockGoogleEventWithImproperUcalid2,
]);
expect(result[0].iCalUID).toBe('eventStrange@google.com');
expect(result[1].iCalUID).toBe('>\u0015-;_^W&p\u001f');
expect(result[0].iCalUID).toBe(context.expected);
});
});
@@ -23,5 +23,5 @@ export const sanitizeCalendarEvent = <T extends Record<string, any>>(
};
const sanitizeString = (value: string): string => {
return value.replace('\u0000', '');
return value.replace('\u0000', '').replace('\x00', '').replace('\x7f', '');
};
@@ -197,6 +197,34 @@ describe('Core REST API Find Many endpoint', () => {
);
});
it('should support filtering on a relation field id', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?filter=companyId[in]:["${TEST_COMPANY_1_ID}"]`,
}).expect(200);
const filteredPeople = response.body.data.people;
expect(filteredPeople.length).toBeGreaterThan(0);
});
it('should fail to filter on a relation field name', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?filter=company[in]:["${TEST_COMPANY_1_ID}"]`,
});
expect(response.body).toMatchInlineSnapshot(`
{
"error": "BadRequestException",
"messages": [
"field 'company' does not exist in 'person' object",
],
"statusCode": 400,
}
`);
});
it('should support ordering Desc of results', async () => {
const descResponse = await makeRestAPIRequest({
method: 'get',