Finalize twenty standard app as workspace migration object and fields (#16353)

# Introduction
Related to https://github.com/twentyhq/core-team-issues/issues/1995
In this PR we're fixing the remaining object/fields validation errors
resulting from standard objects and fields now passing a validation that
wasn't when using the sync metadata

## Key Changes
- **Field naming**: Renamed `iCalUID` to `iCalUid` for consistent
camelCase convention across calendar events
- **Enum standardization**: Uppercased enum values for message channels
(email→EMAIL), message participants (from→FROM, to→TO, cc→CC, bcc→BCC),
and message direction (incoming→INCOMING, outgoing→OUTGOING)
- **Label simplification**: Removed example values from workspace member
number format labels for cleaner UI
- **Migration infrastructure**: Added `isSystemBuild` flag throughout
field metadata service pipeline to allow system-level updates of
standard fields that bypass normal restrictions

## Migrating the existing data
We've created an upgrade command that will identify using the existing
object and field standard id field that needs to be updated, even though
the sync metadata still in usage could have fix them ( and the goal is
to deprecate it by the end of the sprint )
We will call the updateOneField for each of them, we're passing by the
field service in order to battle test what are going to be the temporary
way to handle standard migrations when we will start deprecating the
sync metadata but haven't still refactored the v2 workspace migration to
be workspace agnostic

## Twenty eng migration
Tested the whole migration + upgrade on twenty eng
Here are generated workspace migration
Records are handled natively gracefully too

### ICalUid
```json
{
  "status": "success",
  "workspaceMigration": {
    "relatedFlatEntityMapsKeys": [
      "flatFieldMetadataMaps",
      "flatIndexMaps",
      "flatViewFilterMaps",
      "flatViewGroupMaps",
      "flatViewMaps",
      "flatViewFieldMaps",
      "flatObjectMetadataMaps"
    ],
    "actions": [
      {
        "type": "update_field",
        "fieldMetadataId": "",
        "objectMetadataId": "",
        "updates": [
          {
            "from": "iCalUID",
            "to": "iCalUid",
            "property": "name"
          }
        ]
      }
    ],
    "workspaceId": ""
  }
}
```

### Incoming Outgoing
None as already caps in database somehow
```json
{
  "status": "success",
  "workspaceMigration": {
    "relatedFlatEntityMapsKeys": [
      "flatFieldMetadataMaps",
      "flatIndexMaps",
      "flatViewFilterMaps",
      "flatViewGroupMaps",
      "flatViewMaps",
      "flatViewFieldMaps",
      "flatObjectMetadataMaps"
    ],
    "actions": [],
    "workspaceId": ""
  }
}
```

### EMAIL
```json
{
  "status": "success",
  "workspaceMigration": {
    "relatedFlatEntityMapsKeys": [
      "flatFieldMetadataMaps",
      "flatIndexMaps",
      "flatViewFilterMaps",
      "flatViewGroupMaps",
      "flatViewMaps",
      "flatViewFieldMaps",
      "flatObjectMetadataMaps"
    ],
    "actions": [
      {
        "type": "update_field",
        "fieldMetadataId": "",
        "objectMetadataId": "",
        "updates": [
          {
            "from": "'email'",
            "to": "'EMAIL'",
            "property": "defaultValue"
          },
          {
            "from": [
              {
                "color": "green",
                "id": "",
                "label": "Email",
                "position": 0,
                "value": "email"
              },
              {
                "color": "blue",
                "id": "",
                "label": "SMS",
                "position": 1,
                "value": "sms"
              }
            ],
            "to": [
              {
                "color": "green",
                "id": "",
                "label": "Email",
                "position": 0,
                "value": "EMAIL"
              },
              {
                "color": "blue",
                "id": "",
                "label": "SMS",
                "position": 1,
                "value": "SMS"
              }
            ],
            "property": "options"
          }
        ]
      }
    ],
    "workspaceId": "e"
  }
}
```

### MessageParticipantRole
```json
{
  "status": "success",
  "workspaceMigration": {
    "relatedFlatEntityMapsKeys": [
      "flatFieldMetadataMaps",
      "flatIndexMaps",
      "flatViewFilterMaps",
      "flatViewGroupMaps",
      "flatViewMaps",
      "flatViewFieldMaps",
      "flatObjectMetadataMaps"
    ],
    "actions": [
      {
        "type": "update_field",
        "fieldMetadataId": "",
        "objectMetadataId": "",
        "updates": [
          {
            "from": "'from'",
            "to": "'FROM'",
            "property": "defaultValue"
          },
          {
            "from": [
              {
                "color": "green",
                "id": "",
                "label": "From",
                "position": 0,
                "value": "from"
              },
              {
                "color": "blue",
                "id": "",
                "label": "To",
                "position": 1,
                "value": "to"
              },
              {
                "color": "orange",
                "id": "",
                "label": "Cc",
                "position": 2,
                "value": "cc"
              },
              {
                "color": "red",
                "id": "",
                "label": "Bcc",
                "position": 3,
                "value": "bcc"
              }
            ],
            "to": [
              {
                "color": "green",
                "id": "",
                "label": "From",
                "position": 0,
                "value": "FROM"
              },
              {
                "color": "blue",
                "id": "",
                "label": "To",
                "position": 1,
                "value": "TO"
              },
              {
                "color": "orange",
                "id": "",
                "label": "Cc",
                "position": 2,
                "value": "CC"
              },
              {
                "color": "red",
                "id": "",
                "label": "Bcc",
                "position": 3,
                "value": "BCC"
              }
            ],
            "property": "options"
          }
        ]
      }
    ],
    "workspaceId": ""
  }
}
```

### Workspace member number format labels
```json
{
  "status": "success",
  "workspaceMigration": {
    "relatedFlatEntityMapsKeys": [
      "flatFieldMetadataMaps",
      "flatIndexMaps",
      "flatViewFilterMaps",
      "flatViewGroupMaps",
      "flatViewMaps",
      "flatViewFieldMaps",
      "flatObjectMetadataMaps"
    ],
    "actions": [
      {
        "type": "update_field",
        "fieldMetadataId": "",
        "objectMetadataId": "",
        "updates": [
          {
            "from": [
              {
                "color": "turquoise",
                "id": "",
                "label": "System",
                "position": 0,
                "value": "SYSTEM"
              },
              {
                "color": "blue",
                "id": "",
                "label": "Commas and dot (1,234.56)",
                "position": 1,
                "value": "COMMAS_AND_DOT"
              },
              {
                "color": "green",
                "id": "",
                "label": "Spaces and comma (1 234,56)",
                "position": 2,
                "value": "SPACES_AND_COMMA"
              },
              {
                "color": "orange",
                "id": "",
                "label": "Dots and comma (1.234,56)",
                "position": 3,
                "value": "DOTS_AND_COMMA"
              },
              {
                "color": "purple",
                "id": "",
                "label": "Apostrophe and dot (1'234.56)",
                "position": 4,
                "value": "APOSTROPHE_AND_DOT"
              }
            ],
            "to": [
              {
                "color": "turquoise",
                "id": "",
                "label": "System",
                "position": 0,
                "value": "SYSTEM"
              },
              {
                "color": "blue",
                "id": "",
                "label": "Commas and dot",
                "position": 1,
                "value": "COMMAS_AND_DOT"
              },
              {
                "color": "green",
                "id": "",
                "label": "Spaces and comma",
                "position": 2,
                "value": "SPACES_AND_COMMA"
              },
              {
                "color": "orange",
                "id": "",
                "label": "Dots and comma",
                "position": 3,
                "value": "DOTS_AND_COMMA"
              },
              {
                "color": "purple",
                "id": "",
                "label": "Apostrophe and dot",
                "position": 4,
                "value": "APOSTROPHE_AND_DOT"
              }
            ],
            "property": "options"
          }
        ]
      }
    ],
    "workspaceId": ""
  }
}
```
This commit is contained in:
Paul Rastoin
2025-12-09 14:09:48 +01:00
committed by GitHub
parent be91d870f9
commit 4996f3dd28
69 changed files with 807 additions and 193 deletions
@@ -6,6 +6,7 @@ import { EmailThreadMessageBodyPreview } from '@/activities/emails/components/Em
import { EmailThreadMessageReceivers } from '@/activities/emails/components/EmailThreadMessageReceivers';
import { EmailThreadMessageSender } from '@/activities/emails/components/EmailThreadMessageSender';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { MessageParticipantRole } from 'twenty-shared/types';
const StyledThreadMessage = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@@ -45,7 +46,7 @@ export const EmailThreadMessage = ({
const [isOpen, setIsOpen] = useState(isExpanded);
const receivers = participants.filter(
(participant) => participant.role !== 'from',
(participant) => participant.role !== MessageParticipantRole.FROM,
);
if (!sender || receivers.length === 0) {
@@ -1 +0,0 @@
export type EmailParticipantRole = 'from' | 'to' | 'cc' | 'bcc';
@@ -1,12 +1,12 @@
import { type EmailParticipantRole } from '@/activities/emails/types/EmailParticipantRole';
import { type Person } from '@/people/types/Person';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import { type MessageParticipantRole } from 'twenty-shared/types';
export type EmailThreadMessageParticipant = {
id: string;
displayName: string;
handle: string;
role: EmailParticipantRole;
role: MessageParticipantRole;
messageId: string;
person: Person;
workspaceMember: WorkspaceMember;
@@ -1,5 +1,6 @@
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { MessageParticipantRole } from 'twenty-shared/types';
import { getDisplayNameFromParticipant } from '../getDisplayNameFromParticipant';
describe('getDisplayNameFromParticipant', () => {
@@ -8,7 +9,7 @@ describe('getDisplayNameFromParticipant', () => {
__typename: 'EmailThreadMessageParticipant',
displayName: '',
handle: '',
role: 'from',
role: MessageParticipantRole.FROM,
messageId: '638f52d1-fd55-4a2b-b0f3-9858ea3b2e91',
person: {
__typename: 'Person',
@@ -51,23 +52,23 @@ describe('getDisplayNameFromParticipant', () => {
},
};
const participantWithHandle: any = {
const participantWithHandle = {
displayName: '',
handle: 'user_handle',
role: 'from',
};
role: MessageParticipantRole.FROM,
} as EmailThreadMessageParticipant;
const participantWithDisplayName: any = {
const participantWithDisplayName = {
displayName: 'User123',
handle: '',
role: 'from',
};
role: MessageParticipantRole.FROM,
} as EmailThreadMessageParticipant;
const participantWithoutInfo: any = {
const participantWithoutInfo = {
displayName: '',
handle: '',
role: 'from',
};
role: MessageParticipantRole.FROM,
} as EmailThreadMessageParticipant;
it('should return full name when shouldUseFullName is true', () => {
expect(
@@ -7,6 +7,7 @@ import {
QUERY_DEFAULT_LIMIT_RECORDS,
QUERY_MAX_RECORDS,
} from 'twenty-shared/constants';
import { MessageParticipantRole } from 'twenty-shared/types';
import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useEmailThreadInCommandMenu } from '../useEmailThreadInCommandMenu';
@@ -313,7 +314,10 @@ const mocks = [
}
`,
variables: {
filter: { messageId: { in: ['1', '2'] }, role: { eq: 'from' } },
filter: {
messageId: { in: ['1', '2'] },
role: { eq: MessageParticipantRole.FROM },
},
orderBy: undefined,
lastCursor: undefined,
limit: QUERY_DEFAULT_LIMIT_RECORDS,
@@ -328,7 +332,7 @@ const mocks = [
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-1',
role: 'from',
role: MessageParticipantRole.FROM,
messageId: '1',
},
}),
@@ -339,7 +343,7 @@ const mocks = [
objectNameSingular: 'messageParticipant',
input: {
id: 'messageParticipant-2',
role: 'from',
role: MessageParticipantRole.FROM,
messageId: '2',
},
}),
@@ -401,7 +405,7 @@ describe('useEmailThreadInCommandMenu', () => {
id: 'messageParticipant-1',
messageId: '1',
person: null,
role: 'from',
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
@@ -422,7 +426,7 @@ describe('useEmailThreadInCommandMenu', () => {
id: 'messageParticipant-2',
messageId: '2',
person: null,
role: 'from',
role: MessageParticipantRole.FROM,
workspaceMember: null,
},
subject: '',
@@ -14,6 +14,7 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
// to improve - https://github.com/twentyhq/twenty/issues/12190
@@ -82,7 +83,7 @@ export const useEmailThreadInCommandMenu = () => {
in: messages.map(({ id }) => id),
},
role: {
eq: 'from',
eq: MessageParticipantRole.FROM,
},
},
objectNameSingular: CoreObjectNameSingular.MessageParticipant,
@@ -4,7 +4,7 @@ import { computeMetadataNameFromLabel as computeMetadataNameFromLabelCore } from
// This is needed for form validation and UI components that prefer graceful degradation
export const computeMetadataNameFromLabel = (label: string): string => {
try {
return computeMetadataNameFromLabelCore(label);
return computeMetadataNameFromLabelCore({ label });
} catch {
return '';
}
@@ -3877,7 +3877,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"isUnique": false,
"createdAt": "2025-06-09T18:53:47.000Z",
"updatedAt": "2025-06-09T18:53:47.000Z",
"defaultValue": "'from'",
"defaultValue": "'FROM'",
"options": [
{
"id": "edc6f452-4ecc-4ff7-b30c-18575dbe0bfd",
@@ -8594,7 +8594,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
"__typename": "Field",
"id": "21f219f9-c531-4ab5-9640-69230baa83d0",
"type": "TEXT",
"name": "iCalUID",
"name": "iCalUid",
"isCustom": false,
"isActive": true,
"isSystem": false,
@@ -0,0 +1,380 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultValue,
FieldMetadataType,
FromTo,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
import { AllStandardObjectName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-name.type';
import {
CALENDAR_EVENT_STANDARD_FIELD_IDS,
MESSAGE_CHANNEL_STANDARD_FIELD_IDS,
MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS,
MESSAGE_STANDARD_FIELD_IDS,
WORKSPACE_MEMBER_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
const findStandardFlatObjectMetadataOrThrow = ({
flatObjectMetadataMaps,
standardId,
}: {
flatObjectMetadataMaps: MetadataFlatEntityMaps<'objectMetadata'>;
standardId: string;
}): FlatObjectMetadata => {
const flatObjectMetadata = Object.values(flatObjectMetadataMaps.byId).find(
(flatObjectMetadata) => flatObjectMetadata?.standardId === standardId,
);
if (!isDefined(flatObjectMetadata)) {
throw new Error(`Could not find standard object ${standardId}`);
}
return flatObjectMetadata;
};
const findStandardFlatFieldMetadatawOrThrow = ({
flatFieldMetadataMaps,
flatObjectMetadata,
standardId,
}: {
standardId: string;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: MetadataFlatEntityMaps<'fieldMetadata'>;
}) => {
const objectFlatFieldMetadata = findManyFlatEntityByIdInFlatEntityMapsOrThrow(
{
flatEntityIds: flatObjectMetadata.fieldMetadataIds,
flatEntityMaps: flatFieldMetadataMaps,
},
);
const flatFieldMetadata = objectFlatFieldMetadata.find(
(flatFieldMetadata) => flatFieldMetadata.standardId === standardId,
);
if (!isDefined(flatFieldMetadata)) {
throw new Error(`Could not find standard field ${standardId}`);
}
return flatFieldMetadata;
};
const ENUM_FIELD_OPTIONS_TO_MUTATE = {
messageChannelMessageAssociation: {
fieldStandardId: MESSAGE_STANDARD_FIELD_IDS.direction,
field: 'direction',
defaultValue: {
fromDefaultValue: "'incoming'",
toDefaultValue: "'INCOMING'",
},
options: [
{
fromOption: {
value: 'incoming',
label: 'Incoming',
position: 0,
color: 'green',
},
toOption: {
value: 'INCOMING',
label: 'Incoming',
position: 0,
color: 'green',
},
},
{
fromOption: {
value: 'outgoing',
label: 'Outgoing',
position: 1,
color: 'blue',
},
toOption: {
value: 'OUTGOING',
label: 'Outgoing',
position: 1,
color: 'blue',
},
},
],
},
messageChannel: {
fieldStandardId: MESSAGE_CHANNEL_STANDARD_FIELD_IDS.type,
field: 'type',
defaultValue: { fromDefaultValue: "'email'", toDefaultValue: "'EMAIL'" },
options: [
{
fromOption: {
value: 'email',
label: 'Email',
position: 0,
color: 'green',
},
toOption: {
value: 'EMAIL',
label: 'Email',
position: 0,
color: 'green',
},
},
{
fromOption: { value: 'sms', label: 'SMS', position: 1, color: 'blue' },
toOption: { value: 'SMS', label: 'SMS', position: 1, color: 'blue' },
},
],
},
messageParticipant: {
fieldStandardId: MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS.role,
field: 'role',
defaultValue: { fromDefaultValue: "'from'", toDefaultValue: "'FROM'" },
options: [
{
fromOption: {
value: 'from',
label: 'From',
position: 0,
color: 'green',
},
toOption: { value: 'FROM', label: 'From', position: 0, color: 'green' },
},
{
fromOption: { value: 'to', label: 'To', position: 1, color: 'blue' },
toOption: { value: 'TO', label: 'To', position: 1, color: 'blue' },
},
{
fromOption: { value: 'cc', label: 'Cc', position: 2, color: 'orange' },
toOption: { value: 'CC', label: 'Cc', position: 2, color: 'orange' },
},
{
fromOption: { value: 'bcc', label: 'Bcc', position: 3, color: 'red' },
toOption: { value: 'BCC', label: 'Bcc', position: 3, color: 'red' },
},
],
},
workspaceMember: {
fieldStandardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.numberFormat,
field: 'numberFormat',
defaultValue: { fromDefaultValue: "'system'", toDefaultValue: "'SYSTEM'" },
options: [
{
fromOption: {
value: 'SYSTEM',
label: 'System',
position: 0,
color: 'turquoise',
},
toOption: {
value: 'SYSTEM',
label: 'System',
position: 0,
color: 'turquoise',
},
},
{
fromOption: {
value: 'COMMAS_AND_DOT',
label: 'Commas and dot (1,234.56)',
position: 1,
color: 'blue',
},
toOption: {
value: 'COMMAS_AND_DOT',
label: 'Commas and dot',
position: 1,
color: 'blue',
},
},
{
fromOption: {
value: 'SPACES_AND_COMMA',
label: 'Spaces and comma (1 234,56)',
position: 2,
color: 'green',
},
toOption: {
value: 'SPACES_AND_COMMA',
label: 'Spaces and comma',
position: 2,
color: 'green',
},
},
{
fromOption: {
value: 'DOTS_AND_COMMA',
label: 'Dots and comma (1.234,56)',
position: 3,
color: 'orange',
},
toOption: {
value: 'DOTS_AND_COMMA',
label: 'Dots and comma',
position: 3,
color: 'orange',
},
},
{
fromOption: {
value: 'APOSTROPHE_AND_DOT',
label: "Apostrophe and dot (1'234.56)",
position: 4,
color: 'purple',
},
toOption: {
value: 'APOSTROPHE_AND_DOT',
label: 'Apostrophe and dot',
position: 4,
color: 'purple',
},
},
],
},
} as const satisfies {
[P in AllStandardObjectName]?: {
fieldStandardId: string;
field: AllStandardObjectFieldName<P>;
options: FromTo<FieldMetadataComplexOption, 'option'>[];
defaultValue: FromTo<string, 'defaultValue'>;
};
};
@Command({
name: 'upgrade:1-13:migrate-standard-invalid-entities',
description: 'Migrate standard invalid entities',
})
export class MigrateStandardInvalidEntitiesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly fieldMetadataService: FieldMetadataService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
options,
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
this.logger.log(
`MigrateStandardInvalidEntitiesCommand starting for workspace ${workspaceId}`,
);
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
]);
const flatCalendarEventObject = findStandardFlatObjectMetadataOrThrow({
standardId: STANDARD_OBJECT_IDS.calendarEvent,
flatObjectMetadataMaps,
});
const iCalUidFieldId = findStandardFlatFieldMetadatawOrThrow({
flatFieldMetadataMaps,
flatObjectMetadata: flatCalendarEventObject,
standardId: CALENDAR_EVENT_STANDARD_FIELD_IDS.iCalUid,
}).id;
const allAffectedObjectNames = Object.keys(
ENUM_FIELD_OPTIONS_TO_MUTATE,
).filter(isDefined) as (keyof typeof ENUM_FIELD_OPTIONS_TO_MUTATE)[];
const flatEnumFieldMetadatasToUpdate = allAffectedObjectNames.flatMap<{
id: string;
options: FieldMetadataComplexOption[];
defaultValue: FieldMetadataDefaultValue<FieldMetadataType.SELECT>;
}>((objectName) => {
const { defaultValue, field, options, fieldStandardId } =
ENUM_FIELD_OPTIONS_TO_MUTATE[objectName];
const objectStandardId = STANDARD_OBJECT_IDS[objectName];
const flatObjectMetadata = findStandardFlatObjectMetadataOrThrow({
flatObjectMetadataMaps,
standardId: objectStandardId,
});
const flatFieldMetadata = findStandardFlatFieldMetadatawOrThrow({
flatFieldMetadataMaps,
flatObjectMetadata,
standardId: fieldStandardId,
});
if (
flatFieldMetadata.name !== field ||
!isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.SELECT)
) {
throw new Error(
`Retrieved flat field is not the one expected expected ${field} got ${flatFieldMetadata.name} in workspace ${workspaceId}`,
);
}
const updatedOptions = flatFieldMetadata.options.map((option) => {
const matchingNewOption = options.find(
({ fromOption }) => fromOption.value === option.value,
);
if (!isDefined(matchingNewOption)) {
return option;
}
return {
...matchingNewOption.toOption,
id: option.id,
};
});
const updatedDefaultValue: FieldMetadataDefaultValue<FieldMetadataType.SELECT> =
flatFieldMetadata.defaultValue === defaultValue.fromDefaultValue
? defaultValue.toDefaultValue
: flatFieldMetadata.defaultValue;
return {
id: flatFieldMetadata.id,
options: updatedOptions,
defaultValue: updatedDefaultValue,
};
});
const allUpdates = [
{
id: iCalUidFieldId,
name: 'iCalUid',
},
...flatEnumFieldMetadatasToUpdate,
];
if (!isDryRun) {
for (const updateFieldInput of allUpdates) {
await this.fieldMetadataService.updateOneField({
updateFieldInput,
workspaceId,
isSystemBuild: true,
});
}
}
this.logger.log('Migrated standard invalid entities');
}
}
@@ -5,6 +5,7 @@ import { BackfillPageLayoutUniversalIdentifiersCommand } from 'src/database/comm
import { BackfillViewMainGroupByFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-backfill-view-main-group-by-field-metadata-id.command';
import { CleanEmptyStringNullInTextFieldsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-clean-empty-string-null-in-text-fields.command';
import { DeduplicateRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-deduplicate-role-targets.command';
import { MigrateStandardInvalidEntitiesCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-standard-invalid-entities.command';
import { RenameIndexNameCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-rename-unique-index.command';
import { UpdateRoleTargetsUniqueConstraintMigrationCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-update-role-targets-unique-constraint-migration.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
@@ -12,6 +13,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout-tab.entity';
@@ -40,6 +42,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
DataSourceModule,
ApplicationModule,
WorkspaceCacheModule,
FieldMetadataModule,
],
providers: [
CleanEmptyStringNullInTextFieldsCommand,
@@ -47,6 +50,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BackfillPageLayoutUniversalIdentifiersCommand,
DeduplicateRoleTargetsCommand,
RenameIndexNameCommand,
MigrateStandardInvalidEntitiesCommand,
UpdateRoleTargetsUniqueConstraintMigrationCommand,
],
exports: [
@@ -55,6 +59,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
BackfillPageLayoutUniversalIdentifiersCommand,
DeduplicateRoleTargetsCommand,
RenameIndexNameCommand,
MigrateStandardInvalidEntitiesCommand,
UpdateRoleTargetsUniqueConstraintMigrationCommand,
],
})
@@ -29,6 +29,7 @@ import { SetStandardApplicationNotUninstallableCommand } from 'src/database/comm
import { WorkspaceCustomApplicationIdNonNullableCommand } from 'src/database/commands/upgrade-version-command/1-12/1-12-workspace-custom-application-id-non-nullable-migration.command';
import { BackfillPageLayoutUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-backfill-page-layout-universal-identifiers.command';
import { DeduplicateRoleTargetsCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-deduplicate-role-targets.command';
import { MigrateStandardInvalidEntitiesCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-migrate-standard-invalid-entities.command';
import { UpdateRoleTargetsUniqueConstraintMigrationCommand } from 'src/database/commands/upgrade-version-command/1-13/1-13-update-role-targets-unique-constraint-migration.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
@@ -100,6 +101,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly deduplicateRoleTargetsCommand: DeduplicateRoleTargetsCommand,
protected readonly updateRoleTargetsUniqueConstraintMigrationCommand: UpdateRoleTargetsUniqueConstraintMigrationCommand,
protected readonly backfillPageLayoutUniversalIdentifiersCommand: BackfillPageLayoutUniversalIdentifiersCommand,
protected readonly migrateStandardInvalidEntitiesCommand: MigrateStandardInvalidEntitiesCommand,
) {
super(
workspaceRepository,
@@ -172,6 +174,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.deduplicateRoleTargetsCommand,
this.updateRoleTargetsUniqueConstraintMigrationCommand,
this.backfillPageLayoutUniversalIdentifiersCommand,
this.migrateStandardInvalidEntitiesCommand,
],
afterSyncMetadata: [],
};
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type TimelineThreadDTO } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -115,7 +116,9 @@ export class TimelineMessagingService {
.where('message.messageThreadId = ANY(:messageThreadIds)', {
messageThreadIds,
})
.andWhere('messageParticipant.role = :role', { role: 'from' })
.andWhere('messageParticipant.role = :role', {
role: MessageParticipantRole.FROM,
})
.orderBy('message.messageThreadId')
.distinctOn(['message.messageThreadId', 'messageParticipant.handle'])
.getMany();
@@ -1,7 +1,11 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
export const filterActiveParticipants = (
participants: MessageParticipantWorkspaceEntity[],
): MessageParticipantWorkspaceEntity[] => {
return participants.filter((participant) => participant.role === 'from');
return participants.filter(
(participant) => participant.role === MessageParticipantRole.FROM,
);
};
@@ -1,10 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import {
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input';
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
@@ -48,7 +48,7 @@ export const fromCreateAgentInputToFlatAgent = ({
standardId,
name: isNonEmptyString(createAgentInput.name)
? createAgentInput.name
: computeMetadataNameFromLabel(createAgentInput.label),
: computeMetadataNameFromLabel({ label: createAgentInput.label }),
label: createAgentInput.label,
icon: createAgentInput.icon ?? null,
description: createAgentInput.description ?? null,
@@ -181,6 +181,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
flatViewMaps: existingFlatViewMaps,
flatViewFieldMaps: existingFlatViewFieldMaps,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
isSystemBuild,
});
if (inputTranspilationResult.status === 'fail') {
@@ -26,6 +26,7 @@ type ComputeFlatFieldToUpdateAndRelatedFlatFieldToUpdateArgs = {
rawUpdateFieldInput: UpdateFieldInput;
fromFlatFieldMetadata: FlatFieldMetadata;
flatObjectMetadata: FlatObjectMetadata;
isSystemBuild: boolean;
} & Pick<AllFlatEntityMaps, 'flatFieldMetadataMaps'>;
// Note: Standard override is way too complex we should land a smoother implemenentation once we standardize
// them across every flat entities
@@ -34,11 +35,13 @@ export const computeFlatFieldToUpdateAndRelatedFlatFieldToUpdate = ({
rawUpdateFieldInput,
flatFieldMetadataMaps,
flatObjectMetadata,
isSystemBuild,
}: ComputeFlatFieldToUpdateAndRelatedFlatFieldToUpdateArgs): ComputeFlatFieldToUpdateAndRelatedFlatFieldToUpdateReturnType => {
const { standardOverrides, updatedEditableFieldProperties } =
sanitizeRawUpdateFieldInput({
existingFlatFieldMetadata: fromFlatFieldMetadata,
rawUpdateFieldInput,
isSystemBuild,
});
const isStandardField = isStandardMetadata(fromFlatFieldMetadata);
@@ -48,7 +51,7 @@ export const computeFlatFieldToUpdateAndRelatedFlatFieldToUpdate = ({
existing: fromFlatFieldMetadata,
properties:
FLAT_FIELD_METADATA_EDITABLE_PROPERTIES[
isStandardField ? 'standard' : 'custom'
isStandardField && !isSystemBuild ? 'standard' : 'custom'
],
update: updatedEditableFieldProperties,
}),
@@ -104,7 +107,7 @@ export const computeFlatFieldToUpdateAndRelatedFlatFieldToUpdate = ({
>((relatedFlatFieldMetadataFrom) => {
const relatedMorphPropertiesToUpdateTo =
FLAT_FIELD_METADATA_MORPH_RELATION_EDITABLE_PROPERTIES_ON_SIBLING_MORPH_RELATION_UPDATE_CONSTANT[
isStandardField ? 'standard' : 'custom'
isStandardField && !isSystemBuild ? 'standard' : 'custom'
];
const relatedFlatFieldMetadataTo = {
...mergeUpdateInExistingRecord({
@@ -24,6 +24,7 @@ import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-fiel
type FromUpdateFieldInputToFlatFieldMetadataArgs = {
updateFieldInput: UpdateFieldInput;
workspaceCustomApplicationId: string;
isSystemBuild: boolean;
} & Pick<
AllFlatEntityMaps,
| 'flatObjectMetadataMaps'
@@ -49,6 +50,7 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
flatViewGroupMaps,
flatViewMaps,
flatViewFieldMaps,
isSystemBuild,
}: FromUpdateFieldInputToFlatFieldMetadataArgs): FieldInputTranspilationResult<FlatFieldMetadataAndIndexToUpdate> => {
const updateFieldInputInformalProperties =
extractAndSanitizeObjectStringFields(rawUpdateFieldInput, [
@@ -98,6 +100,7 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
flatObjectMetadata,
fromFlatFieldMetadata: existingFlatFieldMetadataToUpdate,
rawUpdateFieldInput,
isSystemBuild,
});
const { flatFieldMetadatasToCreate, flatIndexMetadatasToCreate } =
@@ -1,10 +1,10 @@
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import {
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
@@ -100,7 +100,9 @@ export const generateMorphOrRelationFlatFieldMetadataPair = ({
label: relationCreationPayload.targetFieldLabel,
name:
targetFieldName ??
computeMetadataNameFromLabel(relationCreationPayload.targetFieldLabel),
computeMetadataNameFromLabel({
label: relationCreationPayload.targetFieldLabel,
}),
objectMetadataId: targetFlatObjectMetadata.id,
type: FieldMetadataType.RELATION,
workspaceId,
@@ -2,13 +2,20 @@ import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export const isFlatFieldMetadataNameSyncedWithLabel = (
export const isFlatFieldMetadataNameSyncedWithLabel = ({
flatFieldMetadata,
isSystemBuild,
}: {
flatFieldMetadata: Pick<
FlatFieldMetadata,
'name' | 'isLabelSyncedWithName' | 'label'
>,
) => {
const computedName = computeMetadataNameFromLabel(flatFieldMetadata.label);
>;
isSystemBuild: boolean;
}) => {
const computedName = computeMetadataNameFromLabel({
label: flatFieldMetadata.label,
applyCustomSuffix: !isSystemBuild,
});
return flatFieldMetadata.name === computedName;
};
@@ -18,10 +18,12 @@ import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standar
type SanitizeRawUpdateFieldInputArgs = {
rawUpdateFieldInput: UpdateFieldInput;
existingFlatFieldMetadata: FlatFieldMetadata;
isSystemBuild: boolean;
};
export const sanitizeRawUpdateFieldInput = ({
existingFlatFieldMetadata,
rawUpdateFieldInput,
isSystemBuild,
}: SanitizeRawUpdateFieldInputArgs) => {
const isStandardField = isStandardMetadata(existingFlatFieldMetadata);
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
@@ -43,7 +45,7 @@ export const sanitizeRawUpdateFieldInput = ({
...option,
}));
if (!isStandardField) {
if (!isStandardField || isSystemBuild) {
return {
updatedEditableFieldProperties,
standardOverrides: null,
@@ -2,16 +2,25 @@ import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
export const areFlatObjectMetadataNamesSyncedWithLabels = (
export const areFlatObjectMetadataNamesSyncedWithLabels = ({
flatObjectdMetadata,
isSystemBuild,
}: {
isSystemBuild: boolean;
flatObjectdMetadata: Pick<
FlatObjectMetadata,
'namePlural' | 'nameSingular' | 'labelPlural' | 'labelSingular'
>,
) => {
>;
}) => {
const [computedSingularName, computedPluralName] = [
flatObjectdMetadata.labelSingular,
flatObjectdMetadata.labelPlural,
].map(computeMetadataNameFromLabel);
].map((label) =>
computeMetadataNameFromLabel({
label,
applyCustomSuffix: !isSystemBuild,
}),
);
return (
flatObjectdMetadata.nameSingular === computedSingularName &&
@@ -8,6 +8,7 @@ import { areFlatObjectMetadataNamesSyncedWithLabels } from 'src/engine/metadata-
import { validateFlatObjectMetadataLabel } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util';
import { validateFlatObjectMetadataNames } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
type ValidateNoOtherObjectWithSameNameExistsOrThrowsParams = {
objectMetadataNameSingular: string;
@@ -36,9 +37,11 @@ export const doesOtherObjectWithSameNameExists = ({
export const validateFlatObjectMetadataNameAndLabels = ({
optimisticFlatObjectMetadataMaps,
flatObjectMetadataToValidate,
buildOptions,
}: {
flatObjectMetadataToValidate: FlatObjectMetadata;
optimisticFlatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
buildOptions: WorkspaceMigrationBuilderOptions;
}): FlatObjectMetadataValidationError[] => {
const errors: FlatObjectMetadataValidationError[] = [];
@@ -58,7 +61,10 @@ export const validateFlatObjectMetadataNameAndLabels = ({
if (
flatObjectMetadataToValidate.isLabelSyncedWithName &&
!areFlatObjectMetadataNamesSyncedWithLabels(flatObjectMetadataToValidate)
!areFlatObjectMetadataNamesSyncedWithLabels({
flatObjectdMetadata: flatObjectMetadataToValidate,
isSystemBuild: buildOptions.isSystemBuild,
})
) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
@@ -17,7 +17,7 @@ export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
}
try {
return computeMetadataNameFromLabelCore(label);
return computeMetadataNameFromLabelCore({ label });
} catch (error) {
if (error instanceof Error) {
throw new InvalidMetadataException(
@@ -82,7 +82,8 @@ export function WorkspaceField<T extends FieldMetadataType>(
generateDefaultValue(options.type)) as FieldMetadataDefaultValue | null;
const name = propertyKey.toString();
const label = options.label.message ?? '';
const isLabelSyncedWithName = computeMetadataNameFromLabel(label) === name;
const isLabelSyncedWithName =
computeMetadataNameFromLabel({ label }) === name;
metadataArgsStorage.addFields({
target: object.constructor,
@@ -1,9 +1,9 @@
import { type MessageDescriptor } from '@lingui/core';
import { isDefined, isUUID } from 'class-validator';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type RelationOnDeleteAction } from 'twenty-shared/types';
import { CustomError } from 'twenty-shared/utils';
import { type ObjectType } from 'typeorm';
import { type RelationOnDeleteAction } from 'twenty-shared/types';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { type RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
@@ -88,7 +88,8 @@ export function WorkspaceRelation<TClass extends object>(
);
const name = propertyKey.toString();
const label = options.label.message ?? '';
const isLabelSyncedWithName = computeMetadataNameFromLabel(label) === name;
const isLabelSyncedWithName =
computeMetadataNameFromLabel({ label }) === name;
if (options.isMorphRelation && !isDefined(options.morphId)) {
throw new CustomError(
@@ -9,7 +9,7 @@ type CalendarEventDataSeed = {
externalUpdatedAt: string;
description: string;
location: string;
iCalUID: string;
iCalUid: string;
conferenceSolution: string;
conferenceLinkPrimaryLinkLabel: string;
conferenceLinkPrimaryLinkUrl: string;
@@ -27,7 +27,7 @@ export const CALENDAR_EVENT_DATA_SEED_COLUMNS: (keyof CalendarEventDataSeed)[] =
'externalUpdatedAt',
'description',
'location',
'iCalUID',
'iCalUid',
'conferenceSolution',
'conferenceLinkPrimaryLinkLabel',
'conferenceLinkPrimaryLinkUrl',
@@ -217,7 +217,7 @@ const GENERATE_CALENDAR_EVENT_SEEDS = (): CalendarEventDataSeed[] => {
).toISOString(),
description: TEMPLATE.description,
location: LOCATION,
iCalUID: `event${INDEX}@calendar.twentycrm.com`,
iCalUid: `event${INDEX}@calendar.twentycrm.com`,
conferenceSolution: CONFERENCE_SOLUTION,
conferenceLinkPrimaryLinkLabel: CONFERENCE_LINK,
conferenceLinkPrimaryLinkUrl: CONFERENCE_LINK,
@@ -1,6 +1,7 @@
import { CONNECTED_ACCOUNT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
import {
MessageChannelSyncStage,
MessageChannelType,
MessageChannelVisibility,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@@ -10,7 +11,7 @@ type MessageChannelDataSeed = {
updatedAt: Date;
deletedAt: Date | null;
isContactAutoCreationEnabled: boolean;
type: string;
type: MessageChannelType;
connectedAccountId: string;
handle: string;
isSyncEnabled: boolean;
@@ -54,7 +55,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: 'email',
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM,
handle: 'tim@apple.dev',
isSyncEnabled: true,
@@ -67,7 +68,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: 'email',
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.JONY,
handle: 'jony.ive@apple.dev',
isSyncEnabled: true,
@@ -80,7 +81,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: 'email',
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.PHIL,
handle: 'phil.schiler@apple.dev',
isSyncEnabled: true,
@@ -93,7 +94,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: 'email',
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM, // Use TIM's connected account for shared inbox
handle: 'support@apple.dev',
isSyncEnabled: true,
@@ -106,7 +107,7 @@ export const MESSAGE_CHANNEL_DATA_SEEDS: MessageChannelDataSeed[] = [
updatedAt: new Date(),
deletedAt: null,
isContactAutoCreationEnabled: true,
type: 'email',
type: MessageChannelType.EMAIL,
connectedAccountId: CONNECTED_ACCOUNT_DATA_SEED_IDS.TIM, // Use TIM's connected account for shared inbox
handle: 'sales@apple.dev',
isSyncEnabled: true,
@@ -1,3 +1,5 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { MESSAGE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/message-data-seeds.constant';
import { PERSON_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/person-data-seeds.constant';
import {
@@ -14,7 +16,7 @@ export type MessageParticipantDataSeed = {
personId: string;
displayName: string;
handle: string;
role: string;
role: MessageParticipantRole;
messageId: string;
};
@@ -226,7 +228,9 @@ const CREATE_MESSAGE_PARTICIPANTS = (
for (let I = 0; I < TOTAL_PARTICIPANTS; I++) {
const IS_SENDER = I === 0;
const ROLE = IS_SENDER ? 'from' : 'to';
const ROLE = IS_SENDER
? MessageParticipantRole.FROM
: MessageParticipantRole.TO;
const HANDLE = IS_SENDER ? 'outgoing' : 'incoming';
// Random date within the last 3 months
@@ -271,8 +271,8 @@ export const STANDARD_OBJECTS = {
location: {
universalIdentifier: CALENDAR_EVENT_STANDARD_FIELD_IDS.location,
},
iCalUID: {
universalIdentifier: CALENDAR_EVENT_STANDARD_FIELD_IDS.iCalUID,
iCalUid: {
universalIdentifier: CALENDAR_EVENT_STANDARD_FIELD_IDS.iCalUid,
},
conferenceSolution: {
universalIdentifier:
@@ -210,11 +210,11 @@ export const buildCalendarEventStandardFlatFieldMetadatas = ({
},
standardFieldMetadataIdByObjectAndFieldName,
}),
iCalUID: createStandardFieldFlatMetadata({
iCalUid: createStandardFieldFlatMetadata({
objectName: 'calendarEvent',
workspaceId,
options: {
fieldName: 'iCalUID',
fieldName: 'iCalUid',
type: FieldMetadataType.TEXT,
label: 'iCal UID',
description: 'iCal UID',
@@ -9,6 +9,7 @@ import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/tw
import { createStandardFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-relation-field-flat-metadata.util';
import { type StandardFieldMetadataIdByObjectAndFieldName } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-field-metadata-id-by-object-and-field-name.util';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
export const buildMessageChannelMessageAssociationStandardFlatFieldMetadatas =
({
@@ -128,10 +129,20 @@ export const buildMessageChannelMessageAssociationStandardFlatFieldMetadatas =
description: 'Message Direction',
icon: 'IconDirection',
isNullable: false,
defaultValue: "'incoming'",
defaultValue: `'${MessageDirection.INCOMING}'`,
options: [
{ value: 'incoming', label: 'Incoming', position: 0, color: 'green' },
{ value: 'outgoing', label: 'Outgoing', position: 1, color: 'blue' },
{
value: MessageDirection.INCOMING,
label: 'Incoming',
position: 0,
color: 'green',
},
{
value: MessageDirection.OUTGOING,
label: 'Outgoing',
position: 1,
color: 'blue',
},
],
createdAt,
},
@@ -9,6 +9,7 @@ import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/tw
import { createStandardFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-relation-field-flat-metadata.util';
import { type StandardFieldMetadataIdByObjectAndFieldName } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-field-metadata-id-by-object-and-field-name.util';
import { MessageChannelType } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
export const buildMessageChannelStandardFlatFieldMetadatas = ({
createdAt,
@@ -138,10 +139,20 @@ export const buildMessageChannelStandardFlatFieldMetadatas = ({
description: 'Channel Type',
icon: 'IconMessage',
isNullable: false,
defaultValue: "'email'",
defaultValue: `'${MessageChannelType.EMAIL}'`,
options: [
{ value: 'email', label: 'Email', position: 0, color: 'green' },
{ value: 'sms', label: 'SMS', position: 1, color: 'blue' },
{
value: MessageChannelType.EMAIL,
label: 'Email',
position: 0,
color: 'green',
},
{
value: MessageChannelType.SMS,
label: 'SMS',
position: 1,
color: 'blue',
},
],
createdAt,
},
@@ -1,5 +1,6 @@
import {
FieldMetadataType,
MessageParticipantRole,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
@@ -99,12 +100,32 @@ export const buildMessageParticipantStandardFlatFieldMetadatas = ({
description: 'Role',
icon: 'IconAt',
isNullable: false,
defaultValue: "'from'",
defaultValue: `'${MessageParticipantRole.FROM}'`,
options: [
{ value: 'from', label: 'From', position: 0, color: 'green' },
{ value: 'to', label: 'To', position: 1, color: 'blue' },
{ value: 'cc', label: 'Cc', position: 2, color: 'orange' },
{ value: 'bcc', label: 'Bcc', position: 3, color: 'red' },
{
value: MessageParticipantRole.FROM,
label: 'From',
position: 0,
color: 'green',
},
{
value: MessageParticipantRole.TO,
label: 'To',
position: 1,
color: 'blue',
},
{
value: MessageParticipantRole.CC,
label: 'Cc',
position: 2,
color: 'orange',
},
{
value: MessageParticipantRole.BCC,
label: 'Bcc',
position: 3,
color: 'red',
},
],
createdAt,
},
@@ -9,6 +9,7 @@ import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/tw
import { createStandardFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/create-standard-relation-field-flat-metadata.util';
import { type StandardFieldMetadataIdByObjectAndFieldName } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-field-metadata-id-by-object-and-field-name.util';
import { WorkspaceMemberNumberFormatEnum } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export const buildWorkspaceMemberStandardFlatFieldMetadatas = ({
createdAt,
@@ -296,30 +297,35 @@ export const buildWorkspaceMemberStandardFlatFieldMetadatas = ({
icon: 'IconNumbers',
isSystem: true,
isNullable: false,
defaultValue: "'SYSTEM'",
defaultValue: `'${WorkspaceMemberNumberFormatEnum.SYSTEM}'`,
options: [
{ value: 'SYSTEM', label: 'System', position: 0, color: 'turquoise' },
{
value: 'COMMAS_AND_DOT',
label: 'Commas and dot (1,234.56)',
value: WorkspaceMemberNumberFormatEnum.SYSTEM,
label: 'System',
position: 0,
color: 'turquoise',
},
{
value: WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT,
label: 'Commas and dot',
position: 1,
color: 'blue',
},
{
value: 'SPACES_AND_COMMA',
label: 'Spaces and comma (1 234,56)',
value: WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA,
label: 'Spaces and comma',
position: 2,
color: 'green',
},
{
value: 'DOTS_AND_COMMA',
label: 'Dots and comma (1.234,56)',
value: WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA,
label: 'Dots and comma',
position: 3,
color: 'orange',
},
{
value: 'APOSTROPHE_AND_DOT',
label: "Apostrophe and dot (1'234.56)",
value: WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT,
label: 'Apostrophe and dot',
position: 4,
color: 'purple',
},
@@ -146,7 +146,10 @@ export class FlatFieldMetadataValidatorService {
if (
flatFieldMetadataToValidate.isLabelSyncedWithName &&
!isFlatFieldMetadataNameSyncedWithLabel(flatFieldMetadataToValidate)
!isFlatFieldMetadataNameSyncedWithLabel({
flatFieldMetadata: flatFieldMetadataToValidate,
isSystemBuild: buildOptions.isSystemBuild,
})
) {
validationResult.errors.push({
code: FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
@@ -318,7 +321,10 @@ export class FlatFieldMetadataValidatorService {
if (
flatFieldMetadataToValidate.isLabelSyncedWithName &&
!isFlatFieldMetadataNameSyncedWithLabel(flatFieldMetadataToValidate)
!isFlatFieldMetadataNameSyncedWithLabel({
flatFieldMetadata: flatFieldMetadataToValidate,
isSystemBuild: buildOptions.isSystemBuild,
})
) {
validationResult.errors.push({
code: FieldMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
@@ -65,6 +65,7 @@ export class FlatObjectMetadataValidatorService {
...validateFlatObjectMetadataNameAndLabels({
optimisticFlatObjectMetadataMaps,
flatObjectMetadataToValidate: updatedFlatObjectMetadata,
buildOptions,
}),
);
@@ -202,6 +203,7 @@ export class FlatObjectMetadataValidatorService {
...validateFlatObjectMetadataNameAndLabels({
optimisticFlatObjectMetadataMaps,
flatObjectMetadataToValidate,
buildOptions,
}),
);
@@ -102,7 +102,7 @@ export const CALENDAR_EVENT_STANDARD_FIELD_IDS = {
externalUpdatedAt: '20202020-b355-4c18-8825-ef42c8a5a755',
description: '20202020-52c4-4266-a98f-e90af0b4d271',
location: '20202020-641a-4ffe-960d-c3c186d95b17',
iCalUID: '20202020-f24b-45f4-b6a3-d2f9fcb98714',
iCalUid: '20202020-f24b-45f4-b6a3-d2f9fcb98714',
conferenceSolution: '20202020-1c3f-4b5a-b526-5411a82179eb',
conferenceLink: '20202020-35da-43ef-9ca0-e936e9dc237b',
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
@@ -272,7 +272,7 @@ export class CalDAVClient {
return {
id: objectUrl,
title: event.summary || 'Untitled Event',
iCalUID: event.uid || '',
iCalUid: event.uid || '',
description: event.description || '',
startsAt: event.start.toISOString(),
endsAt: event.end.toISOString(),
@@ -120,6 +120,6 @@ describe('formatGoogleCalendarEvents', () => {
mockGoogleEventWithImproperUcalid,
]);
expect(result[0].iCalUID).toBe(context.expected);
expect(result[0].iCalUid).toBe(context.expected);
});
});
@@ -38,7 +38,7 @@ const formatGoogleCalendarEvent = (
externalUpdatedAt: event.updated ?? '',
description: event.description ?? '',
location: event.location ?? '',
iCalUID: event.iCalUID ?? '',
iCalUid: event.iCalUID ?? '',
conferenceSolution:
event.conferenceData?.conferenceSolution?.key?.type ?? '',
conferenceLinkLabel: event.conferenceData?.entryPoints?.[0]?.uri ?? '',
@@ -63,7 +63,7 @@ const formatGoogleCalendarEvent = (
'externalUpdatedAt',
'description',
'location',
'iCalUID',
'iCalUid',
'conferenceSolution',
'conferenceLinkLabel',
'conferenceLinkUrl',
@@ -100,7 +100,7 @@ describe('formatMicrosoftCalendarEvents', () => {
mockMicrosoftEventWithImproperData2,
]);
expect(result[0].iCalUID).toBe('eventStrange@microsoft.com');
expect(result[1].iCalUID).toBe('>\u0015-;_^W&p\u001f');
expect(result[0].iCalUid).toBe('eventStrange@microsoft.com');
expect(result[1].iCalUid).toBe('>\u0015-;_^W&p\u001f');
});
});
@@ -42,7 +42,7 @@ const formatMicrosoftCalendarEvent = (event: Event): FetchedCalendarEvent => {
externalUpdatedAt: event.lastModifiedDateTime ?? '',
description: event.body?.content ?? '',
location: event.location?.displayName ?? '',
iCalUID: event.iCalUId ?? '',
iCalUid: event.iCalUId ?? '',
conferenceSolution: event.onlineMeetingProvider ?? '',
conferenceLinkLabel: event.onlineMeeting?.joinUrl ?? '',
conferenceLinkUrl: event.onlineMeeting?.joinUrl ?? '',
@@ -66,7 +66,7 @@ const formatMicrosoftCalendarEvent = (event: Event): FetchedCalendarEvent => {
'externalUpdatedAt',
'description',
'location',
'iCalUID',
'iCalUid',
'conferenceSolution',
'conferenceLinkLabel',
'conferenceLinkUrl',
@@ -44,21 +44,21 @@ export class CalendarSaveEventsService {
const existingCalendarEvents = await calendarEventRepository.find({
where: {
iCalUID: Any(
fetchedCalendarEvents.map((event) => event.iCalUID as string),
iCalUid: Any(
fetchedCalendarEvents.map((event) => event.iCalUid as string),
),
},
});
const fetchedCalendarEventsWithDBEvents: FetchedCalendarEventWithDBEvent[] =
fetchedCalendarEvents.map((event): FetchedCalendarEventWithDBEvent => {
const existingEventWithSameiCalUID = existingCalendarEvents.find(
(existingEvent) => existingEvent.iCalUID === event.iCalUID,
const existingEventWithSameiCalUid = existingCalendarEvents.find(
(existingEvent) => existingEvent.iCalUid === event.iCalUid,
);
return {
fetchedCalendarEvent: event,
existingCalendarEvent: existingEventWithSameiCalUID ?? null,
existingCalendarEvent: existingEventWithSameiCalUid ?? null,
newlyCreatedCalendarEvent: null,
};
});
@@ -78,7 +78,7 @@ export class CalendarSaveEventsService {
.map(
({ fetchedCalendarEvent }) =>
({
iCalUID: fetchedCalendarEvent.iCalUID,
iCalUid: fetchedCalendarEvent.iCalUid,
title: fetchedCalendarEvent.title,
description: fetchedCalendarEvent.description,
startsAt: fetchedCalendarEvent.startsAt,
@@ -113,7 +113,7 @@ export class CalendarSaveEventsService {
({ fetchedCalendarEvent, existingCalendarEvent }) => {
const savedCalendarEvent = savedCalendarEvents.find(
(savedCalendarEvent) =>
savedCalendarEvent.iCalUID === fetchedCalendarEvent.iCalUID,
savedCalendarEvent.iCalUid === fetchedCalendarEvent.iCalUid,
);
return {
@@ -132,13 +132,13 @@ export class CalendarSaveEventsService {
.map(({ fetchedCalendarEvent, existingCalendarEvent }) => {
if (!existingCalendarEvent) {
throw new Error(
`Existing calendar event with iCalUID ${fetchedCalendarEvent.iCalUID} not found - should never happen`,
`Existing calendar event with iCalUid ${fetchedCalendarEvent.iCalUid} not found - should never happen`,
);
}
return {
id: existingCalendarEvent.id,
iCalUID: fetchedCalendarEvent.iCalUID,
iCalUid: fetchedCalendarEvent.iCalUid,
title: fetchedCalendarEvent.title,
description: fetchedCalendarEvent.description,
startsAt: fetchedCalendarEvent.startsAt,
@@ -184,7 +184,7 @@ export class CalendarSaveEventsService {
if (!calendarEventId) {
throw new Error(
`Calendar event id not found for event with iCalUID ${fetchedCalendarEvent.iCalUID} - should never happen`,
`Calendar event id not found for event with iCalUid ${fetchedCalendarEvent.iCalUid} - should never happen`,
);
}
@@ -213,7 +213,7 @@ export class CalendarSaveEventsService {
.flatMap(({ newlyCreatedCalendarEvent, fetchedCalendarEvent }) => {
if (!newlyCreatedCalendarEvent?.id) {
throw new Error(
`Newly created calendar event with iCalUID ${fetchedCalendarEvent.iCalUID} not found - should never happen`,
`Newly created calendar event with iCalUid ${fetchedCalendarEvent.iCalUid} not found - should never happen`,
);
}
@@ -233,7 +233,7 @@ export class CalendarSaveEventsService {
.flatMap(({ fetchedCalendarEvent, existingCalendarEvent }) => {
if (!existingCalendarEvent?.id) {
throw new Error(
`Existing calendar event with iCalUID ${fetchedCalendarEvent.iCalUID} not found - should never happen`,
`Existing calendar event with iCalUid ${fetchedCalendarEvent.iCalUid} not found - should never happen`,
);
}
@@ -5,7 +5,7 @@ export const mapCalendarEventsByICalUID = (
): Map<string, string> => {
return new Map<string, string>(
existingCalendarEvents.map((calendarEvent) => [
calendarEvent.iCalUID ?? '',
calendarEvent.iCalUid ?? '',
calendarEvent.id,
]),
);
@@ -31,7 +31,7 @@ const createMockCalendarEvent = (
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
iCalUID: '',
iCalUid: '',
conferenceSolution: '',
calendarChannelEventAssociations: [],
calendarEventParticipants: [],
@@ -127,14 +127,14 @@ export class CalendarEventWorkspaceEntity extends BaseWorkspaceEntity {
location: string | null;
@WorkspaceField({
standardId: CALENDAR_EVENT_STANDARD_FIELD_IDS.iCalUID,
standardId: CALENDAR_EVENT_STANDARD_FIELD_IDS.iCalUid,
type: FieldMetadataType.TEXT,
label: msg`iCal UID`,
description: msg`iCal UID`,
icon: 'IconKey',
})
@WorkspaceIsNullable()
iCalUID: string | null;
iCalUid: string | null;
@WorkspaceField({
standardId: CALENDAR_EVENT_STANDARD_FIELD_IDS.conferenceSolution,
@@ -8,7 +8,7 @@ export type FetchedCalendarEventParticipant = {
export type FetchedCalendarEvent = {
id: string;
title: string;
iCalUID: string;
iCalUid: string;
description: string;
startsAt: string;
endsAt: string;
@@ -2,6 +2,7 @@ import { Scope } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { And, Any, ILike, In, Not, Or } from 'typeorm';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
@@ -87,7 +88,10 @@ export class BlocklistItemDeleteMessagesJob {
continue;
}
const rolesToDelete: ('from' | 'to')[] = ['from', 'to'];
const rolesToDelete = [
MessageParticipantRole.FROM,
MessageParticipantRole.TO,
] as const;
const messageChannels = await messageChannelRepository.find({
select: {
@@ -47,8 +47,8 @@ export enum MessageChannelVisibility {
}
export enum MessageChannelType {
EMAIL = 'email',
SMS = 'sms',
EMAIL = 'EMAIL',
SMS = 'SMS',
}
export enum MessageChannelContactAutoCreationPolicy {
@@ -1,6 +1,10 @@
import { msg } from '@lingui/core/macro';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import { FieldMetadataType, RelationOnDeleteAction } from 'twenty-shared/types';
import {
FieldMetadataType,
MessageParticipantRole,
RelationOnDeleteAction,
} from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
@@ -39,14 +43,34 @@ export class MessageParticipantWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Role`,
icon: 'IconAt',
options: [
{ value: 'from', label: 'From', position: 0, color: 'green' },
{ value: 'to', label: 'To', position: 1, color: 'blue' },
{ value: 'cc', label: 'Cc', position: 2, color: 'orange' },
{ value: 'bcc', label: 'Bcc', position: 3, color: 'red' },
{
value: MessageParticipantRole.FROM,
label: 'From',
position: 0,
color: 'green',
},
{
value: MessageParticipantRole.TO,
label: 'To',
position: 1,
color: 'blue',
},
{
value: MessageParticipantRole.CC,
label: 'Cc',
position: 2,
color: 'orange',
},
{
value: MessageParticipantRole.BCC,
label: 'Bcc',
position: 3,
color: 'red',
},
],
defaultValue: "'from'",
defaultValue: `'${MessageParticipantRole.FROM}'`,
})
role: string;
role: MessageParticipantRole;
@WorkspaceField({
standardId: MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS.handle,
@@ -1,3 +1,5 @@
import { type MessageParticipantRole } from 'twenty-shared/types';
export type GmailMessage = {
historyId: string;
externalId: string;
@@ -13,7 +15,7 @@ export type GmailMessage = {
};
export type Participant = {
role: 'from' | 'to' | 'cc' | 'bcc';
role: MessageParticipantRole;
handle: string;
displayName: string;
};
@@ -1,5 +1,6 @@
import { type gmail_v1 as gmailV1 } from 'googleapis';
import planer from 'planer';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
@@ -43,17 +44,28 @@ export const parseAndFormatGmailMessage = (
const participants = [
...(from
? formatAddressObjectAsParticipants([{ address: from }], 'from')
? formatAddressObjectAsParticipants(
[{ address: from }],
MessageParticipantRole.FROM,
)
: []),
...(toParticipants
? formatAddressObjectAsParticipants(
[{ address: toParticipants, name: '' }],
'to',
MessageParticipantRole.TO,
)
: []),
...(cc
? formatAddressObjectAsParticipants(
[{ address: cc }],
MessageParticipantRole.CC,
)
: []),
...(cc ? formatAddressObjectAsParticipants([{ address: cc }], 'cc') : []),
...(bcc
? formatAddressObjectAsParticipants([{ address: bcc }], 'bcc')
? formatAddressObjectAsParticipants(
[{ address: bcc }],
MessageParticipantRole.BCC,
)
: []),
];
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { type ImapFlow } from 'imapflow';
import { Address, type Email as ParsedMail } from 'postal-mime';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
@@ -203,11 +204,11 @@ export class ImapGetMessagesService {
private extractParticipants(parsed: ParsedMail) {
const addressFields = [
{ field: parsed.from, role: 'from' as const },
{ field: parsed.to, role: 'to' as const },
{ field: parsed.cc, role: 'cc' as const },
{ field: parsed.bcc, role: 'bcc' as const },
];
{ field: parsed.from, role: MessageParticipantRole.FROM },
{ field: parsed.to, role: MessageParticipantRole.TO },
{ field: parsed.cc, role: MessageParticipantRole.CC },
{ field: parsed.bcc, role: MessageParticipantRole.BCC },
] as const;
return addressFields.flatMap(({ field, role }) =>
formatAddressObjectAsParticipants(this.extractAddresses(field), role),
@@ -2,7 +2,10 @@ import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import {
ConnectedAccountProvider,
MessageParticipantRole,
} from 'twenty-shared/types';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { GoogleOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/google/google-oauth2-client-manager.service';
@@ -97,12 +100,12 @@ describe('Microsoft get messages service', () => {
{
displayName: 'John l',
handle: 'john.l@outlook.fr',
role: 'from',
role: MessageParticipantRole.FROM,
},
{
displayName: 'Walker',
handle: 'walker@felixacme.onmicrosoft.com',
role: 'to',
role: MessageParticipantRole.TO,
},
],
attachments: [],
@@ -130,22 +133,22 @@ describe('Microsoft get messages service', () => {
{
displayName: 'Microsoft',
handle: 'microsoft-noreply@microsoft.com',
role: 'from',
role: MessageParticipantRole.FROM,
},
{
displayName: 'Walker',
handle: 'walker@felixacme.onmicrosoft.com',
role: 'to',
role: MessageParticipantRole.TO,
},
{
displayName: 'Antoine',
handle: 'antoine@gmail.com',
role: 'cc',
role: MessageParticipantRole.CC,
},
{
displayName: 'Cyril@acme2.com',
handle: 'cyril@acme2.com',
role: 'cc',
role: MessageParticipantRole.CC,
},
],
attachments: [],
@@ -184,7 +187,7 @@ describe('Microsoft get messages service', () => {
displayName: responseExample.body.sender.emailAddress.name,
handle:
responseExample.body.sender.emailAddress.address.toLowerCase(),
role: 'from',
role: MessageParticipantRole.FROM,
},
],
attachments: [],
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { type EmailAddress } from 'addressparser';
import { isDefined } from 'twenty-shared/utils';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
@@ -109,16 +110,28 @@ export class MicrosoftGetMessagesService {
const participants = [
...(safeParseFrom
? formatAddressObjectAsParticipants(safeParseFrom, 'from')
? formatAddressObjectAsParticipants(
safeParseFrom,
MessageParticipantRole.FROM,
)
: []),
...(safeParseTo
? formatAddressObjectAsParticipants(safeParseTo, 'to')
? formatAddressObjectAsParticipants(
safeParseTo,
MessageParticipantRole.TO,
)
: []),
...(safeParseCc
? formatAddressObjectAsParticipants(safeParseCc, 'cc')
? formatAddressObjectAsParticipants(
safeParseCc,
MessageParticipantRole.CC,
)
: []),
...(safeParseBcc
? formatAddressObjectAsParticipants(safeParseBcc, 'bcc')
? formatAddressObjectAsParticipants(
safeParseBcc,
MessageParticipantRole.BCC,
)
: []),
];
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import chunk from 'lodash.chunk';
import { isDefined } from 'twenty-shared/utils';
import { MessageParticipantRole } from 'twenty-shared/types';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
@@ -55,7 +56,7 @@ export class MessagingDeleteGroupEmailMessagesService {
'message.messageParticipants',
'participant',
'participant.role = :role',
{ role: 'from' },
{ role: MessageParticipantRole.FROM },
)
.where('mcma.messageChannelId = :messageChannelId', {
messageChannelId,
@@ -1,7 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FieldActorSource } from 'twenty-shared/types';
import { FieldActorSource, MessageParticipantRole } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -56,8 +56,16 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
messageThreadExternalId: 'thread-1',
direction: MessageDirection.OUTGOING,
participants: [
{ role: 'from', handle: 'test@example.com', displayName: 'Test User' },
{ role: 'to', handle: 'contact@company.com', displayName: 'Contact' },
{
role: MessageParticipantRole.FROM,
handle: 'test@example.com',
displayName: 'Test User',
},
{
role: MessageParticipantRole.TO,
handle: 'contact@company.com',
displayName: 'Contact',
},
],
},
{
@@ -70,11 +78,23 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
messageThreadExternalId: 'thread-1',
direction: MessageDirection.INCOMING,
participants: [
{ role: 'from', handle: 'contact@company.com', displayName: 'Contact' },
{ role: 'to', handle: 'test@example.com', displayName: 'Test User' },
{ role: 'to', handle: 'personal@gmail.com', displayName: 'Personal' },
{
role: 'to',
role: MessageParticipantRole.FROM,
handle: 'contact@company.com',
displayName: 'Contact',
},
{
role: MessageParticipantRole.TO,
handle: 'test@example.com',
displayName: 'Test User',
},
{
role: MessageParticipantRole.TO,
handle: 'personal@gmail.com',
displayName: 'Personal',
},
{
role: MessageParticipantRole.TO,
handle: 'team@lists.company.com',
displayName: 'Group email',
},
@@ -199,7 +219,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
...mockMessages[1],
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'tim@apple.com',
displayName: 'participant email',
},
@@ -221,7 +241,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
{
handle: 'tim@apple.com',
displayName: 'participant email',
role: 'from',
role: MessageParticipantRole.FROM,
shouldCreateContact: true,
messageId: 'db-message-id-2',
},
@@ -237,7 +257,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
...mockMessages[0],
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'test@gmail.com',
displayName: 'participant personal email',
},
@@ -265,7 +285,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
...mockMessages[0],
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'connected@account.com',
displayName: 'participant that is the Connected Account',
},
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { FieldActorSource, MessageParticipantRole } from 'twenty-shared/types';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -66,8 +66,9 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
return messageId
? message.participants.map((participant: Participant) => {
const fromHandle =
message.participants.find((p) => p.role === 'from')?.handle ||
'';
message.participants.find(
(p) => p.role === MessageParticipantRole.FROM,
)?.handle || '';
const isMessageSentByConnectedAccount =
handleAliases.includes(fromHandle) ||
@@ -1,3 +1,6 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
export const messagingGetMessagesServiceGetMessages = [
@@ -8,15 +11,15 @@ export const messagingGetMessagesServiceGetMessages = [
text: 'Just a test',
headerMessageId: '<d45b9f1c@PR0P264MB2911.FRAP264.PROD.OUTLOOK.COM>',
messageThreadExternalId: 'AAQkAGZlMDQ1NjU5Lk=',
direction: 'OUTGOING',
direction: MessageDirection.OUTGOING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'from@acme.com',
displayName: 'From',
},
{
role: 'to',
role: MessageParticipantRole.TO,
handle: 'to@acme.com',
displayName: 'To',
},
@@ -30,15 +33,15 @@ export const messagingGetMessagesServiceGetMessages = [
text: 'Just a test',
headerMessageId: '<d45b9f1c@PR0P264MB2911.FRAP264.PROD.OUTLOOK.COM>',
messageThreadExternalId: 'AAQkAGZlMDQ1NjU5Lk=',
direction: 'OUTGOING',
direction: MessageDirection.OUTGOING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'from@acme.com',
displayName: 'From',
},
{
role: 'to',
role: MessageParticipantRole.TO,
handle: 'to@external.com',
displayName: 'To',
},
@@ -52,19 +55,19 @@ export const messagingGetMessagesServiceGetMessages = [
text: 'Learn with interactive sandboxes, curated collections, and bookmarks',
headerMessageId: '<AC70000@mails.microsoft.com>',
messageThreadExternalId: 'AAQkAGZlMDQ1NNc=',
direction: 'INCOMING',
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'learn@mails.microsoft.com',
displayName: 'Microsoft Learn',
},
{
role: 'to',
role: MessageParticipantRole.TO,
handle: 'to@gmail.com',
displayName: 'To',
},
],
attachments: [],
},
] as MessageWithParticipants[];
] satisfies MessageWithParticipants[];
@@ -1,3 +1,5 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { messagingGetMessagesServiceGetMessages } from 'src/modules/messaging/message-import-manager/utils/__mocks__/messages';
@@ -64,7 +66,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'noreply@example.com',
displayName: 'No Reply',
},
@@ -81,7 +83,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'support@company.com',
displayName: 'Support Team',
},
@@ -98,7 +100,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'john@example.com',
displayName: 'John Doe',
},
@@ -126,7 +128,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'noreply@example.com',
displayName: 'No Reply',
},
@@ -174,7 +176,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: [
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'john.doe@example.com',
displayName: 'John Doe',
},
@@ -1,3 +1,5 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
describe('formatAddressObjectAsParticipants', () => {
@@ -7,16 +9,19 @@ describe('formatAddressObjectAsParticipants', () => {
{ name: 'Jane Smith', address: 'jane.smith@example.com ' },
];
const result = formatAddressObjectAsParticipants(addresses, 'from');
const result = formatAddressObjectAsParticipants(
addresses,
MessageParticipantRole.FROM,
);
expect(result).toEqual([
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'john.doe@example.com',
displayName: 'John Doe',
},
{
role: 'from',
role: MessageParticipantRole.FROM,
handle: 'jane.smith@example.com',
displayName: 'Jane Smith',
},
@@ -29,7 +34,10 @@ describe('formatAddressObjectAsParticipants', () => {
address: 'john.doe',
};
const result = formatAddressObjectAsParticipants([addressObject], 'to');
const result = formatAddressObjectAsParticipants(
[addressObject],
MessageParticipantRole.TO,
);
expect(result).toEqual([]);
});
@@ -40,7 +48,10 @@ describe('formatAddressObjectAsParticipants', () => {
address: '',
};
const result = formatAddressObjectAsParticipants([addressObject], 'to');
const result = formatAddressObjectAsParticipants(
[addressObject],
MessageParticipantRole.TO,
);
expect(result).toEqual([]);
});
@@ -51,11 +62,14 @@ describe('formatAddressObjectAsParticipants', () => {
address: 'John.Doe@example.com',
};
const result = formatAddressObjectAsParticipants([addressObject], 'to');
const result = formatAddressObjectAsParticipants(
[addressObject],
MessageParticipantRole.TO,
);
expect(result).toEqual([
{
role: 'to',
role: MessageParticipantRole.TO,
handle: 'john.doe@example.com',
displayName: 'John Doe',
},
@@ -1,3 +1,5 @@
import { MessageParticipantRole } from 'twenty-shared/types';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { isGroupEmail } from 'src/modules/messaging/message-import-manager/utils/is-group-email';
@@ -8,7 +10,7 @@ export const filterOutGroupEmails = (messages: MessageWithParticipants[]) => {
}
const fromParticipant = message.participants.find(
(participant) => participant.role === 'from',
(participant) => participant.role === MessageParticipantRole.FROM,
);
if (!fromParticipant || !fromParticipant.handle) {
@@ -1,4 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type MessageParticipantRole } from 'twenty-shared/types';
import { type Participant } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message.type';
import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
@@ -9,7 +10,7 @@ const removeSpacesAndLowerCase = (email: string): string => {
export const formatAddressObjectAsParticipants = (
addressObjects: EmailAddress[],
role: 'from' | 'to' | 'cc' | 'bcc',
role: MessageParticipantRole,
): Participant[] => {
const participants = addressObjects.map((addressObject) => {
const address = addressObject.address;
@@ -410,25 +410,25 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
},
{
value: WorkspaceMemberNumberFormatEnum.COMMAS_AND_DOT,
label: 'Commas and dot (1,234.56)',
label: 'Commas and dot',
position: 1,
color: 'blue',
},
{
value: WorkspaceMemberNumberFormatEnum.SPACES_AND_COMMA,
label: 'Spaces and comma (1 234,56)',
label: 'Spaces and comma',
position: 2,
color: 'green',
},
{
value: WorkspaceMemberNumberFormatEnum.DOTS_AND_COMMA,
label: 'Dots and comma (1.234,56)',
label: 'Dots and comma',
position: 3,
color: 'orange',
},
{
value: WorkspaceMemberNumberFormatEnum.APOSTROPHE_AND_DOT,
label: "Apostrophe and dot (1'234.56)",
label: 'Apostrophe and dot',
position: 4,
color: 'purple',
},
@@ -1,10 +1,9 @@
import { capitalize } from '../utils';
import { RESERVED_METADATA_NAME_KEYWORDS } from './reserved-metadata-name-keywords.constant';
export const sanitizeReservedKeyword = (name: string): string => {
export const addCustomSuffixIfIsReserved = (name: string): string => {
if (!name) return name;
return RESERVED_METADATA_NAME_KEYWORDS.includes(name)
? `${name}${capitalize('custom')}`
? `${name}Custom`
: name;
};
@@ -1,9 +1,14 @@
import { addCustomSuffixIfIsReserved } from '@/metadata/add-custom-suffix-if-reserved.util';
import camelCase from 'lodash.camelcase';
import { slugify } from 'transliteration';
import { sanitizeReservedKeyword } from './sanitize-reserved-keyword.util';
export const computeMetadataNameFromLabel = (label: string): string => {
export const computeMetadataNameFromLabel = ({
label,
applyCustomSuffix = true,
}: {
label: string;
applyCustomSuffix?: boolean;
}): string => {
if (!label) return '';
const prefixedLabel = /^\d/.test(label) ? `n${label}` : label;
@@ -22,5 +27,7 @@ export const computeMetadataNameFromLabel = (label: string): string => {
const computedName = camelCase(formattedString);
return sanitizeReservedKeyword(computedName);
return applyCustomSuffix
? addCustomSuffixIfIsReserved(computedName)
: computedName;
};
+1 -1
View File
@@ -7,6 +7,7 @@
* |___/
*/
export { addCustomSuffixIfIsReserved } from './add-custom-suffix-if-reserved.util';
export { ALL_METADATA_NAME } from './all-metadata-name.constant';
export type { AllMetadataName } from './all-metadata-name.type';
export { computeMetadataNameFromLabel } from './compute-metadata-name-from-label.util';
@@ -20,5 +21,4 @@ export {
CORE_OBJECT_NAMES,
RESERVED_METADATA_NAME_KEYWORDS,
} from './reserved-metadata-name-keywords.constant';
export { sanitizeReservedKeyword } from './sanitize-reserved-keyword.util';
export { STANDARD_OBJECT_IDS } from './standard-object-ids';
@@ -0,0 +1,6 @@
export enum MessageParticipantRole {
'FROM' = 'FROM',
'TO' = 'TO',
'CC' = 'CC',
'BCC' = 'BCC',
}
@@ -114,6 +114,7 @@ export type { FromTo } from './FromToType';
export { HTTPMethod } from './HttpMethod';
export type { IsEmptyRecord } from './IsEmptyRecord.type';
export type { IsExactly } from './IsExactly';
export { MessageParticipantRole } from './MessageParticipantRole';
export type { ModifiedProperties } from './ModifiedProperties';
export type { NonNullableRequired } from './NonNullableRequired';
export type { Nullable } from './Nullable';