feat: migrate ConnectedAccount infrastructure entities to metadata schema (#18784)

## Summary

- Migrates 4 entities (`connectedAccount`, `messageChannel`,
`calendarChannel`, `messageFolder`) from per-workspace schemas to the
shared `core` metadata schema
- Introduces a `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag to control
the migration: when enabled, reads come from core metadata and all
writes are dual-written to both workspace and core
- Extracts 12 enums from workspace entity files to `twenty-shared` for
reuse across frontend and backend
- Creates new TypeORM entities, metadata services, GraphQL
resolvers/DTOs, and exception interceptors per entity
- Each entity owns its own data access module
(`ConnectedAccountDataAccessModule`, `MessageChannelDataAccessModule`,
`CalendarChannelDataAccessModule`, `MessageFolderDataAccessModule`) — no
umbrella infrastructure module
- Adds a 1.20 upgrade command that backfills data from workspace schemas
to core (preserving UUIDs) and enables the feature flag
- Replaces direct repository access with data access service calls
across ~50 files in messaging, calendar, and connected-account modules
- Adds `lastSignedInAt` and `oidcTokenClaims` fields to the new
`ConnectedAccountEntity`
- Drops unused `lastSyncHistoryId` field from the migrated connected
account entity

## Test plan

- [x] Lint passes (`npx nx lint:diff-with-main twenty-server`)
- [x] Typecheck passes (`npx nx typecheck twenty-server`)
- [x] All unit tests pass (477 suites, 4267 tests, 0 failures)
- [ ] Manual test: verify messaging sync works with feature flag
disabled (existing behavior)
- [ ] Manual test: run upgrade command on a workspace, verify data
backfilled to core tables
- [ ] Manual test: verify messaging/calendar sync works with feature
flag enabled (dual-write path)
- [ ] Manual test: verify GraphQL metadata resolvers return correct data
when flag enabled
This commit is contained in:
Charles Bochet
2026-03-20 00:34:58 +01:00
committed by GitHub
parent cd594ce8bd
commit cee4cf6452
149 changed files with 7338 additions and 1699 deletions
@@ -761,6 +761,56 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe<Scalars['Boolean']>;
};
export type CalendarChannel = {
__typename?: 'CalendarChannel';
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
id: Scalars['UUID'];
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
syncCursor?: Maybe<Scalars['String']>;
syncStage: CalendarChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: CalendarChannelSyncStatus;
syncedAt?: Maybe<Scalars['DateTime']>;
throttleFailureCount: Scalars['Float'];
updatedAt: Scalars['DateTime'];
visibility: CalendarChannelVisibility;
};
export enum CalendarChannelContactAutoCreationPolicy {
AS_ORGANIZER = 'AS_ORGANIZER',
AS_PARTICIPANT = 'AS_PARTICIPANT',
AS_PARTICIPANT_AND_ORGANIZER = 'AS_PARTICIPANT_AND_ORGANIZER',
NONE = 'NONE'
}
export enum CalendarChannelSyncStage {
CALENDAR_EVENTS_IMPORT_ONGOING = 'CALENDAR_EVENTS_IMPORT_ONGOING',
CALENDAR_EVENTS_IMPORT_PENDING = 'CALENDAR_EVENTS_IMPORT_PENDING',
CALENDAR_EVENTS_IMPORT_SCHEDULED = 'CALENDAR_EVENTS_IMPORT_SCHEDULED',
CALENDAR_EVENT_LIST_FETCH_ONGOING = 'CALENDAR_EVENT_LIST_FETCH_ONGOING',
CALENDAR_EVENT_LIST_FETCH_PENDING = 'CALENDAR_EVENT_LIST_FETCH_PENDING',
CALENDAR_EVENT_LIST_FETCH_SCHEDULED = 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED',
FAILED = 'FAILED',
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION'
}
export enum CalendarChannelSyncStatus {
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING'
}
export enum CalendarChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING'
}
export type CalendarConfiguration = {
__typename?: 'CalendarConfiguration';
configurationType: WidgetConfigurationType;
@@ -927,6 +977,25 @@ export type ConfigVariablesGroupData = {
variables: Array<ConfigVariable>;
};
export type ConnectedAccountDto = {
__typename?: 'ConnectedAccountDTO';
accessToken?: Maybe<Scalars['String']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<Scalars['JSON']>;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
handleAliases?: Maybe<Array<Scalars['String']>>;
id: Scalars['UUID'];
lastCredentialsRefreshedAt?: Maybe<Scalars['DateTime']>;
lastSignedInAt?: Maybe<Scalars['DateTime']>;
oidcTokenClaims?: Maybe<Scalars['JSON']>;
provider: Scalars['String'];
refreshToken?: Maybe<Scalars['String']>;
scopes?: Maybe<Array<Scalars['String']>>;
updatedAt: Scalars['DateTime'];
userWorkspaceId: Scalars['UUID'];
};
export type ConnectedImapSmtpCaldavAccount = {
__typename?: 'ConnectedImapSmtpCaldavAccount';
accountOwnerId: Scalars['UUID'];
@@ -1008,6 +1077,17 @@ export type CreateApprovedAccessDomainInput = {
email: Scalars['String'];
};
export type CreateCalendarChannelInput = {
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
syncStage: CalendarChannelSyncStage;
visibility: CalendarChannelVisibility;
};
export type CreateCommandMenuItemInput = {
availabilityObjectMetadataId?: InputMaybe<Scalars['UUID']>;
availabilityType?: InputMaybe<CommandMenuItemAvailabilityType>;
@@ -1023,6 +1103,16 @@ export type CreateCommandMenuItemInput = {
workflowVersionId?: InputMaybe<Scalars['UUID']>;
};
export type CreateConnectedAccountInput = {
accessToken?: InputMaybe<Scalars['String']>;
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
provider: Scalars['String'];
refreshToken?: InputMaybe<Scalars['String']>;
scopes?: InputMaybe<Array<Scalars['String']>>;
userWorkspaceId: Scalars['UUID'];
};
export type CreateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -1069,6 +1159,33 @@ export type CreateLogicFunctionFromSourceInput = {
universalIdentifier?: InputMaybe<Scalars['UUID']>;
};
export type CreateMessageChannelInput = {
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
excludeGroupEmails: Scalars['Boolean'];
excludeNonProfessionalEmails: Scalars['Boolean'];
handle: Scalars['String'];
id?: InputMaybe<Scalars['UUID']>;
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
messageFolderImportPolicy: MessageFolderImportPolicy;
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
syncStage: MessageChannelSyncStage;
type: MessageChannelType;
visibility: MessageChannelVisibility;
};
export type CreateMessageFolderInput = {
externalId?: InputMaybe<Scalars['String']>;
id?: InputMaybe<Scalars['UUID']>;
isSentFolder: Scalars['Boolean'];
isSynced: Scalars['Boolean'];
messageChannelId: Scalars['UUID'];
name?: InputMaybe<Scalars['String']>;
parentFolderId?: InputMaybe<Scalars['UUID']>;
pendingSyncAction: MessageFolderPendingSyncAction;
};
export type CreateNavigationMenuItemInput = {
color?: InputMaybe<Scalars['String']>;
folderId?: InputMaybe<Scalars['UUID']>;
@@ -1618,6 +1735,7 @@ export enum FeatureFlagKey {
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
@@ -2289,6 +2407,98 @@ export type MarketplaceAppRoleObjectPermission = {
objectUniversalIdentifier: Scalars['String'];
};
export type MessageChannel = {
__typename?: 'MessageChannel';
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
createdAt: Scalars['DateTime'];
excludeGroupEmails: Scalars['Boolean'];
excludeNonProfessionalEmails: Scalars['Boolean'];
handle: Scalars['String'];
id: Scalars['UUID'];
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
messageFolderImportPolicy: MessageFolderImportPolicy;
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
syncCursor?: Maybe<Scalars['String']>;
syncStage: MessageChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: MessageChannelSyncStatus;
syncedAt?: Maybe<Scalars['DateTime']>;
throttleFailureCount: Scalars['Float'];
throttleRetryAfter?: Maybe<Scalars['DateTime']>;
type: MessageChannelType;
updatedAt: Scalars['DateTime'];
visibility: MessageChannelVisibility;
};
export enum MessageChannelContactAutoCreationPolicy {
NONE = 'NONE',
SENT = 'SENT',
SENT_AND_RECEIVED = 'SENT_AND_RECEIVED'
}
export enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION = 'GROUP_EMAILS_DELETION',
GROUP_EMAILS_IMPORT = 'GROUP_EMAILS_IMPORT',
NONE = 'NONE'
}
export enum MessageChannelSyncStage {
FAILED = 'FAILED',
MESSAGES_IMPORT_ONGOING = 'MESSAGES_IMPORT_ONGOING',
MESSAGES_IMPORT_PENDING = 'MESSAGES_IMPORT_PENDING',
MESSAGES_IMPORT_SCHEDULED = 'MESSAGES_IMPORT_SCHEDULED',
MESSAGE_LIST_FETCH_ONGOING = 'MESSAGE_LIST_FETCH_ONGOING',
MESSAGE_LIST_FETCH_PENDING = 'MESSAGE_LIST_FETCH_PENDING',
MESSAGE_LIST_FETCH_SCHEDULED = 'MESSAGE_LIST_FETCH_SCHEDULED',
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION'
}
export enum MessageChannelSyncStatus {
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING'
}
export enum MessageChannelType {
EMAIL = 'EMAIL',
SMS = 'SMS'
}
export enum MessageChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
SUBJECT = 'SUBJECT'
}
export type MessageFolder = {
__typename?: 'MessageFolder';
createdAt: Scalars['DateTime'];
externalId?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
isSentFolder: Scalars['Boolean'];
isSynced: Scalars['Boolean'];
messageChannelId: Scalars['UUID'];
name?: Maybe<Scalars['String']>;
parentFolderId?: Maybe<Scalars['UUID']>;
pendingSyncAction: MessageFolderPendingSyncAction;
syncCursor?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
};
export enum MessageFolderImportPolicy {
ALL_FOLDERS = 'ALL_FOLDERS',
SELECTED_FOLDERS = 'SELECTED_FOLDERS'
}
export enum MessageFolderPendingSyncAction {
FOLDER_DELETION = 'FOLDER_DELETION',
NONE = 'NONE'
}
export type MetadataEvent = {
__typename?: 'MetadataEvent';
metadataName: Scalars['String'];
@@ -2361,8 +2571,10 @@ export type Mutation = {
createApplicationRegistration: CreateApplicationRegistration;
createApplicationRegistrationVariable: ApplicationRegistrationVariable;
createApprovedAccessDomain: ApprovedAccessDomain;
createCalendarChannel: CalendarChannel;
createChatThread: AgentChatThread;
createCommandMenuItem: CommandMenuItem;
createConnectedAccount: ConnectedAccountDto;
createDatabaseConfigVariable: Scalars['Boolean'];
createDevelopmentApplication: DevelopmentApplication;
createEmailingDomain: EmailingDomain;
@@ -2370,6 +2582,8 @@ export type Mutation = {
createManyViewFieldGroups: Array<ViewFieldGroup>;
createManyViewFields: Array<ViewField>;
createManyViewGroups: Array<ViewGroup>;
createMessageChannel: MessageChannel;
createMessageFolder: MessageFolder;
createNavigationMenuItem: NavigationMenuItem;
createOIDCIdentityProvider: SetupSso;
createObjectEvent: Analytics;
@@ -2397,12 +2611,16 @@ export type Mutation = {
deleteApplicationRegistration: Scalars['Boolean'];
deleteApplicationRegistrationVariable: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCalendarChannel: CalendarChannel;
deleteCommandMenuItem: CommandMenuItem;
deleteConnectedAccount: ConnectedAccountDto;
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteEmailingDomain: Scalars['Boolean'];
deleteFrontComponent: FrontComponent;
deleteJobs: DeleteJobsResponse;
deleteMessageChannel: MessageChannel;
deleteMessageFolder: MessageFolder;
deleteNavigationMenuItem: NavigationMenuItem;
deleteOneAgent: Agent;
deleteOneField: Field;
@@ -2487,10 +2705,14 @@ export type Mutation = {
updateApiKey?: Maybe<ApiKey>;
updateApplicationRegistration: ApplicationRegistration;
updateApplicationRegistrationVariable: ApplicationRegistrationVariable;
updateCalendarChannel: CalendarChannel;
updateCommandMenuItem: CommandMenuItem;
updateConnectedAccount: ConnectedAccountDto;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateFrontComponent: FrontComponent;
updateLabPublicFeatureFlag: FeatureFlag;
updateMessageChannel: MessageChannel;
updateMessageFolder: MessageFolder;
updateNavigationMenuItem: NavigationMenuItem;
updateOneAgent: Agent;
updateOneApplicationVariable: Scalars['Boolean'];
@@ -2608,11 +2830,21 @@ export type MutationCreateApprovedAccessDomainArgs = {
};
export type MutationCreateCalendarChannelArgs = {
input: CreateCalendarChannelInput;
};
export type MutationCreateCommandMenuItemArgs = {
input: CreateCommandMenuItemInput;
};
export type MutationCreateConnectedAccountArgs = {
input: CreateConnectedAccountInput;
};
export type MutationCreateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
@@ -2651,6 +2883,16 @@ export type MutationCreateManyViewGroupsArgs = {
};
export type MutationCreateMessageChannelArgs = {
input: CreateMessageChannelInput;
};
export type MutationCreateMessageFolderArgs = {
input: CreateMessageFolderInput;
};
export type MutationCreateNavigationMenuItemArgs = {
input: CreateNavigationMenuItemInput;
};
@@ -2789,11 +3031,21 @@ export type MutationDeleteApprovedAccessDomainArgs = {
};
export type MutationDeleteCalendarChannelArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteCommandMenuItemArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteConnectedAccountArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
@@ -2815,6 +3067,16 @@ export type MutationDeleteJobsArgs = {
};
export type MutationDeleteMessageChannelArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteMessageFolderArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteNavigationMenuItemArgs = {
id: Scalars['UUID'];
};
@@ -3217,11 +3479,21 @@ export type MutationUpdateApplicationRegistrationVariableArgs = {
};
export type MutationUpdateCalendarChannelArgs = {
input: UpdateCalendarChannelInput;
};
export type MutationUpdateCommandMenuItemArgs = {
input: UpdateCommandMenuItemInput;
};
export type MutationUpdateConnectedAccountArgs = {
input: UpdateConnectedAccountInput;
};
export type MutationUpdateDatabaseConfigVariableArgs = {
key: Scalars['String'];
value: Scalars['JSON'];
@@ -3238,6 +3510,16 @@ export type MutationUpdateLabPublicFeatureFlagArgs = {
};
export type MutationUpdateMessageChannelArgs = {
input: UpdateMessageChannelInput;
};
export type MutationUpdateMessageFolderArgs = {
input: UpdateMessageFolderInput;
};
export type MutationUpdateNavigationMenuItemArgs = {
input: UpdateOneNavigationMenuItemInput;
};
@@ -3939,6 +4221,8 @@ export type Query = {
applicationRegistrationTarballUrl?: Maybe<Scalars['String']>;
barChartData: BarChartData;
billingPortalSession: BillingSession;
calendarChannel?: Maybe<CalendarChannel>;
calendarChannels: Array<CalendarChannel>;
chatMessages: Array<AgentMessage>;
chatThread: AgentChatThread;
chatThreads: AgentChatThreadConnection;
@@ -3946,6 +4230,8 @@ export type Query = {
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
commandMenuItem?: Maybe<CommandMenuItem>;
commandMenuItems: Array<CommandMenuItem>;
connectedAccount?: Maybe<ConnectedAccountDto>;
connectedAccounts: Array<ConnectedAccountDto>;
currentUser: User;
currentWorkspace: Workspace;
enterpriseCheckoutSession?: Maybe<Scalars['String']>;
@@ -4020,6 +4306,10 @@ export type Query = {
indexMetadatas: IndexConnection;
lineChartData: LineChartData;
listPlans: Array<BillingPlan>;
messageChannel?: Maybe<MessageChannel>;
messageChannels: Array<MessageChannel>;
messageFolder?: Maybe<MessageFolder>;
messageFolders: Array<MessageFolder>;
minimalMetadata: MinimalMetadata;
navigationMenuItem?: Maybe<NavigationMenuItem>;
navigationMenuItems: Array<NavigationMenuItem>;
@@ -4061,6 +4351,16 @@ export type QueryBillingPortalSessionArgs = {
};
export type QueryCalendarChannelArgs = {
id: Scalars['UUID'];
};
export type QueryCalendarChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryChatMessagesArgs = {
threadId: Scalars['UUID'];
};
@@ -4094,6 +4394,11 @@ export type QueryCommandMenuItemArgs = {
};
export type QueryConnectedAccountArgs = {
id: Scalars['UUID'];
};
export type QueryEnterpriseCheckoutSessionArgs = {
billingInterval?: InputMaybe<Scalars['String']>;
};
@@ -4357,6 +4662,26 @@ export type QueryLineChartDataArgs = {
};
export type QueryMessageChannelArgs = {
id: Scalars['UUID'];
};
export type QueryMessageChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryMessageFolderArgs = {
id: Scalars['UUID'];
};
export type QueryMessageFoldersArgs = {
messageChannelId?: InputMaybe<Scalars['UUID']>;
};
export type QueryNavigationMenuItemArgs = {
id: Scalars['UUID'];
};
@@ -4871,6 +5196,18 @@ export type UpdateApplicationRegistrationVariablePayload = {
value?: InputMaybe<Scalars['String']>;
};
export type UpdateCalendarChannelInput = {
id: Scalars['UUID'];
update: UpdateCalendarChannelInputUpdates;
};
export type UpdateCalendarChannelInputUpdates = {
contactAutoCreationPolicy?: InputMaybe<CalendarChannelContactAutoCreationPolicy>;
isContactAutoCreationEnabled?: InputMaybe<Scalars['Boolean']>;
isSyncEnabled?: InputMaybe<Scalars['Boolean']>;
visibility?: InputMaybe<CalendarChannelVisibility>;
};
export type UpdateCommandMenuItemInput = {
availabilityObjectMetadataId?: InputMaybe<Scalars['UUID']>;
availabilityType?: InputMaybe<CommandMenuItemAvailabilityType>;
@@ -4884,6 +5221,18 @@ export type UpdateCommandMenuItemInput = {
shortLabel?: InputMaybe<Scalars['String']>;
};
export type UpdateConnectedAccountInput = {
id: Scalars['UUID'];
update: UpdateConnectedAccountInputUpdates;
};
export type UpdateConnectedAccountInputUpdates = {
accessToken?: InputMaybe<Scalars['String']>;
handleAliases?: InputMaybe<Array<Scalars['String']>>;
refreshToken?: InputMaybe<Scalars['String']>;
scopes?: InputMaybe<Array<Scalars['String']>>;
};
export type UpdateFieldInput = {
defaultValue?: InputMaybe<Scalars['JSON']>;
description?: InputMaybe<Scalars['String']>;
@@ -4940,6 +5289,33 @@ export type UpdateLogicFunctionFromSourceInputUpdates = {
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type UpdateMessageChannelInput = {
id: Scalars['UUID'];
update: UpdateMessageChannelInputUpdates;
};
export type UpdateMessageChannelInputUpdates = {
contactAutoCreationPolicy?: InputMaybe<MessageChannelContactAutoCreationPolicy>;
excludeGroupEmails?: InputMaybe<Scalars['Boolean']>;
excludeNonProfessionalEmails?: InputMaybe<Scalars['Boolean']>;
isContactAutoCreationEnabled?: InputMaybe<Scalars['Boolean']>;
isSyncEnabled?: InputMaybe<Scalars['Boolean']>;
messageFolderImportPolicy?: InputMaybe<MessageFolderImportPolicy>;
visibility?: InputMaybe<MessageChannelVisibility>;
};
export type UpdateMessageFolderInput = {
id: Scalars['UUID'];
update: UpdateMessageFolderInputUpdates;
};
export type UpdateMessageFolderInputUpdates = {
isSynced?: InputMaybe<Scalars['Boolean']>;
name?: InputMaybe<Scalars['String']>;
pendingSyncAction?: InputMaybe<MessageFolderPendingSyncAction>;
syncCursor?: InputMaybe<Scalars['String']>;
};
export type UpdateNavigationMenuItemInput = {
color?: InputMaybe<Scalars['String']>;
folderId?: InputMaybe<Scalars['UUID']>;
@@ -1549,6 +1549,7 @@ enum FeatureFlagKey {
IS_DRAFT_EMAIL_ENABLED
IS_RICH_TEXT_V1_MIGRATED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_CONNECTED_ACCOUNT_MIGRATED
}
type SSOIdentityProvider {
@@ -2204,6 +2205,163 @@ type AgentTurn {
createdAt: DateTime!
}
type CalendarChannel {
id: UUID!
handle: String!
syncStatus: CalendarChannelSyncStatus!
syncStage: CalendarChannelSyncStage!
visibility: CalendarChannelVisibility!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy!
isSyncEnabled: Boolean!
syncCursor: String
syncedAt: DateTime
syncStageStartedAt: DateTime
throttleFailureCount: Float!
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum CalendarChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum CalendarChannelSyncStage {
PENDING_CONFIGURATION
CALENDAR_EVENT_LIST_FETCH_PENDING
CALENDAR_EVENT_LIST_FETCH_SCHEDULED
CALENDAR_EVENT_LIST_FETCH_ONGOING
CALENDAR_EVENTS_IMPORT_PENDING
CALENDAR_EVENTS_IMPORT_SCHEDULED
CALENDAR_EVENTS_IMPORT_ONGOING
FAILED
}
enum CalendarChannelVisibility {
METADATA
SHARE_EVERYTHING
}
enum CalendarChannelContactAutoCreationPolicy {
AS_PARTICIPANT_AND_ORGANIZER
AS_PARTICIPANT
AS_ORGANIZER
NONE
}
type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
accessToken: String
refreshToken: String
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
connectionParameters: JSON
lastSignedInAt: DateTime
oidcTokenClaims: JSON
userWorkspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
type MessageChannel {
id: UUID!
visibility: MessageChannelVisibility!
handle: String!
type: MessageChannelType!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
syncCursor: String
syncedAt: DateTime
syncStatus: MessageChannelSyncStatus!
syncStage: MessageChannelSyncStage!
syncStageStartedAt: DateTime
throttleFailureCount: Float!
throttleRetryAfter: DateTime
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum MessageChannelVisibility {
METADATA
SUBJECT
SHARE_EVERYTHING
}
enum MessageChannelType {
EMAIL
SMS
}
enum MessageChannelContactAutoCreationPolicy {
SENT_AND_RECEIVED
SENT
NONE
}
enum MessageFolderImportPolicy {
ALL_FOLDERS
SELECTED_FOLDERS
}
enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION
GROUP_EMAILS_IMPORT
NONE
}
enum MessageChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum MessageChannelSyncStage {
PENDING_CONFIGURATION
MESSAGE_LIST_FETCH_PENDING
MESSAGE_LIST_FETCH_SCHEDULED
MESSAGE_LIST_FETCH_ONGOING
MESSAGES_IMPORT_PENDING
MESSAGES_IMPORT_SCHEDULED
MESSAGES_IMPORT_ONGOING
FAILED
}
type MessageFolder {
id: UUID!
name: String
syncCursor: String
isSentFolder: Boolean!
isSynced: Boolean!
parentFolderId: UUID
externalId: String
pendingSyncAction: MessageFolderPendingSyncAction!
messageChannelId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum MessageFolderPendingSyncAction {
FOLDER_DELETION
NONE
}
type CollectionHash {
collectionName: AllMetadataName!
hash: String!
@@ -3010,6 +3168,14 @@ type Query {
findWorkspaceFromInviteHash(inviteHash: String!): Workspace!
validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken!
getSSOIdentityProviders: [FindAvailableSSOIDP!]!
messageFolders(messageChannelId: UUID): [MessageFolder!]!
messageFolder(id: UUID!): MessageFolder
calendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
calendarChannel(id: UUID!): CalendarChannel
messageChannels(connectedAccountId: UUID): [MessageChannel!]!
messageChannel(id: UUID!): MessageChannel
connectedAccounts: [ConnectedAccountDTO!]!
connectedAccount(id: UUID!): ConnectedAccountDTO
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
@@ -3307,6 +3473,18 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createMessageFolder(input: CreateMessageFolderInput!): MessageFolder!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
deleteMessageFolder(id: UUID!): MessageFolder!
createCalendarChannel(input: CreateCalendarChannelInput!): CalendarChannel!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
deleteCalendarChannel(id: UUID!): CalendarChannel!
createMessageChannel(input: CreateMessageChannelInput!): MessageChannel!
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
deleteMessageChannel(id: UUID!): MessageChannel!
createConnectedAccount(input: CreateConnectedAccountInput!): ConnectedAccountDTO!
updateConnectedAccount(input: UpdateConnectedAccountInput!): ConnectedAccountDTO!
deleteConnectedAccount(id: UUID!): ConnectedAccountDTO!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
@@ -4203,6 +4381,105 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
input CreateMessageFolderInput {
id: UUID
name: String
isSentFolder: Boolean!
isSynced: Boolean!
externalId: String
pendingSyncAction: MessageFolderPendingSyncAction!
messageChannelId: UUID!
parentFolderId: UUID
}
input UpdateMessageFolderInput {
id: UUID!
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageFolderInputUpdates {
name: String
syncCursor: String
isSynced: Boolean
pendingSyncAction: MessageFolderPendingSyncAction
}
input CreateCalendarChannelInput {
id: UUID
handle: String!
visibility: CalendarChannelVisibility!
syncStage: CalendarChannelSyncStage!
connectedAccountId: UUID!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy!
isSyncEnabled: Boolean!
}
input UpdateCalendarChannelInput {
id: UUID!
update: UpdateCalendarChannelInputUpdates!
}
input UpdateCalendarChannelInputUpdates {
visibility: CalendarChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Boolean
}
input CreateMessageChannelInput {
id: UUID
handle: String!
visibility: MessageChannelVisibility!
type: MessageChannelType!
syncStage: MessageChannelSyncStage!
connectedAccountId: UUID!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
}
input UpdateMessageChannelInput {
id: UUID!
update: UpdateMessageChannelInputUpdates!
}
input UpdateMessageChannelInputUpdates {
visibility: MessageChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
isSyncEnabled: Boolean
excludeNonProfessionalEmails: Boolean
excludeGroupEmails: Boolean
}
input CreateConnectedAccountInput {
id: UUID
handle: String!
provider: String!
accessToken: String
refreshToken: String
scopes: [String!]
userWorkspaceId: UUID!
}
input UpdateConnectedAccountInput {
id: UUID!
update: UpdateConnectedAccountInputUpdates!
}
input UpdateConnectedAccountInputUpdates {
accessToken: String
refreshToken: String
handleAliases: [String!]
scopes: [String!]
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
@@ -1252,7 +1252,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED'
export interface SSOIdentityProvider {
id: Scalars['UUID']
@@ -1877,6 +1877,108 @@ export interface AgentTurn {
__typename: 'AgentTurn'
}
export interface CalendarChannel {
id: Scalars['UUID']
handle: Scalars['String']
syncStatus: CalendarChannelSyncStatus
syncStage: CalendarChannelSyncStage
visibility: CalendarChannelVisibility
isContactAutoCreationEnabled: Scalars['Boolean']
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Scalars['Boolean']
syncCursor?: Scalars['String']
syncedAt?: Scalars['DateTime']
syncStageStartedAt?: Scalars['DateTime']
throttleFailureCount: Scalars['Float']
connectedAccountId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'CalendarChannel'
}
export type CalendarChannelSyncStatus = 'NOT_SYNCED' | 'ONGOING' | 'ACTIVE' | 'FAILED_INSUFFICIENT_PERMISSIONS' | 'FAILED_UNKNOWN'
export type CalendarChannelSyncStage = 'PENDING_CONFIGURATION' | 'CALENDAR_EVENT_LIST_FETCH_PENDING' | 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED' | 'CALENDAR_EVENT_LIST_FETCH_ONGOING' | 'CALENDAR_EVENTS_IMPORT_PENDING' | 'CALENDAR_EVENTS_IMPORT_SCHEDULED' | 'CALENDAR_EVENTS_IMPORT_ONGOING' | 'FAILED'
export type CalendarChannelVisibility = 'METADATA' | 'SHARE_EVERYTHING'
export type CalendarChannelContactAutoCreationPolicy = 'AS_PARTICIPANT_AND_ORGANIZER' | 'AS_PARTICIPANT' | 'AS_ORGANIZER' | 'NONE'
export interface ConnectedAccountDTO {
id: Scalars['UUID']
handle: Scalars['String']
provider: Scalars['String']
accessToken?: Scalars['String']
refreshToken?: Scalars['String']
lastCredentialsRefreshedAt?: Scalars['DateTime']
authFailedAt?: Scalars['DateTime']
handleAliases?: Scalars['String'][]
scopes?: Scalars['String'][]
connectionParameters?: Scalars['JSON']
lastSignedInAt?: Scalars['DateTime']
oidcTokenClaims?: Scalars['JSON']
userWorkspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'ConnectedAccountDTO'
}
export interface MessageChannel {
id: Scalars['UUID']
visibility: MessageChannelVisibility
handle: Scalars['String']
type: MessageChannelType
isContactAutoCreationEnabled: Scalars['Boolean']
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
excludeNonProfessionalEmails: Scalars['Boolean']
excludeGroupEmails: Scalars['Boolean']
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction
isSyncEnabled: Scalars['Boolean']
syncCursor?: Scalars['String']
syncedAt?: Scalars['DateTime']
syncStatus: MessageChannelSyncStatus
syncStage: MessageChannelSyncStage
syncStageStartedAt?: Scalars['DateTime']
throttleFailureCount: Scalars['Float']
throttleRetryAfter?: Scalars['DateTime']
connectedAccountId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'MessageChannel'
}
export type MessageChannelVisibility = 'METADATA' | 'SUBJECT' | 'SHARE_EVERYTHING'
export type MessageChannelType = 'EMAIL' | 'SMS'
export type MessageChannelContactAutoCreationPolicy = 'SENT_AND_RECEIVED' | 'SENT' | 'NONE'
export type MessageFolderImportPolicy = 'ALL_FOLDERS' | 'SELECTED_FOLDERS'
export type MessageChannelPendingGroupEmailsAction = 'GROUP_EMAILS_DELETION' | 'GROUP_EMAILS_IMPORT' | 'NONE'
export type MessageChannelSyncStatus = 'NOT_SYNCED' | 'ONGOING' | 'ACTIVE' | 'FAILED_INSUFFICIENT_PERMISSIONS' | 'FAILED_UNKNOWN'
export type MessageChannelSyncStage = 'PENDING_CONFIGURATION' | 'MESSAGE_LIST_FETCH_PENDING' | 'MESSAGE_LIST_FETCH_SCHEDULED' | 'MESSAGE_LIST_FETCH_ONGOING' | 'MESSAGES_IMPORT_PENDING' | 'MESSAGES_IMPORT_SCHEDULED' | 'MESSAGES_IMPORT_ONGOING' | 'FAILED'
export interface MessageFolder {
id: Scalars['UUID']
name?: Scalars['String']
syncCursor?: Scalars['String']
isSentFolder: Scalars['Boolean']
isSynced: Scalars['Boolean']
parentFolderId?: Scalars['UUID']
externalId?: Scalars['String']
pendingSyncAction: MessageFolderPendingSyncAction
messageChannelId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
__typename: 'MessageFolder'
}
export type MessageFolderPendingSyncAction = 'FOLDER_DELETION' | 'NONE'
export interface CollectionHash {
collectionName: AllMetadataName
hash: Scalars['String']
@@ -2622,6 +2724,14 @@ export interface Query {
findWorkspaceFromInviteHash: Workspace
validatePasswordResetToken: ValidatePasswordResetToken
getSSOIdentityProviders: FindAvailableSSOIDP[]
messageFolders: MessageFolder[]
messageFolder?: MessageFolder
calendarChannels: CalendarChannel[]
calendarChannel?: CalendarChannel
messageChannels: MessageChannel[]
messageChannel?: MessageChannel
connectedAccounts: ConnectedAccountDTO[]
connectedAccount?: ConnectedAccountDTO
webhooks: Webhook[]
webhook?: Webhook
minimalMetadata: MinimalMetadata
@@ -2818,6 +2928,18 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createMessageFolder: MessageFolder
updateMessageFolder: MessageFolder
deleteMessageFolder: MessageFolder
createCalendarChannel: CalendarChannel
updateCalendarChannel: CalendarChannel
deleteCalendarChannel: CalendarChannel
createMessageChannel: MessageChannel
updateMessageChannel: MessageChannel
deleteMessageChannel: MessageChannel
createConnectedAccount: ConnectedAccountDTO
updateConnectedAccount: ConnectedAccountDTO
deleteConnectedAccount: ConnectedAccountDTO
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
@@ -4863,6 +4985,88 @@ export interface AgentTurnGenqlSelection{
__scalar?: boolean | number
}
export interface CalendarChannelGenqlSelection{
id?: boolean | number
handle?: boolean | number
syncStatus?: boolean | number
syncStage?: boolean | number
visibility?: boolean | number
isContactAutoCreationEnabled?: boolean | number
contactAutoCreationPolicy?: boolean | number
isSyncEnabled?: boolean | number
syncCursor?: boolean | number
syncedAt?: boolean | number
syncStageStartedAt?: boolean | number
throttleFailureCount?: boolean | number
connectedAccountId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ConnectedAccountDTOGenqlSelection{
id?: boolean | number
handle?: boolean | number
provider?: boolean | number
accessToken?: boolean | number
refreshToken?: boolean | number
lastCredentialsRefreshedAt?: boolean | number
authFailedAt?: boolean | number
handleAliases?: boolean | number
scopes?: boolean | number
connectionParameters?: boolean | number
lastSignedInAt?: boolean | number
oidcTokenClaims?: boolean | number
userWorkspaceId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MessageChannelGenqlSelection{
id?: boolean | number
visibility?: boolean | number
handle?: boolean | number
type?: boolean | number
isContactAutoCreationEnabled?: boolean | number
contactAutoCreationPolicy?: boolean | number
messageFolderImportPolicy?: boolean | number
excludeNonProfessionalEmails?: boolean | number
excludeGroupEmails?: boolean | number
pendingGroupEmailsAction?: boolean | number
isSyncEnabled?: boolean | number
syncCursor?: boolean | number
syncedAt?: boolean | number
syncStatus?: boolean | number
syncStage?: boolean | number
syncStageStartedAt?: boolean | number
throttleFailureCount?: boolean | number
throttleRetryAfter?: boolean | number
connectedAccountId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MessageFolderGenqlSelection{
id?: boolean | number
name?: boolean | number
syncCursor?: boolean | number
isSentFolder?: boolean | number
isSynced?: boolean | number
parentFolderId?: boolean | number
externalId?: boolean | number
pendingSyncAction?: boolean | number
messageChannelId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CollectionHashGenqlSelection{
collectionName?: boolean | number
hash?: boolean | number
@@ -5670,6 +5874,14 @@ export interface QueryGenqlSelection{
findWorkspaceFromInviteHash?: (WorkspaceGenqlSelection & { __args: {inviteHash: Scalars['String']} })
validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} })
getSSOIdentityProviders?: FindAvailableSSOIDPGenqlSelection
messageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
messageFolder?: (MessageFolderGenqlSelection & { __args: {id: Scalars['UUID']} })
calendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
calendarChannel?: (CalendarChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
messageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
messageChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
connectedAccounts?: ConnectedAccountDTOGenqlSelection
connectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
webhooks?: WebhookGenqlSelection
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
minimalMetadata?: MinimalMetadataGenqlSelection
@@ -5891,6 +6103,18 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: CreateMessageFolderInput} })
updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} })
deleteMessageFolder?: (MessageFolderGenqlSelection & { __args: {id: Scalars['UUID']} })
createCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: CreateCalendarChannelInput} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
deleteCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
createMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: CreateMessageChannelInput} })
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
deleteMessageChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
createConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {input: CreateConnectedAccountInput} })
updateConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {input: UpdateConnectedAccountInput} })
deleteConnectedAccount?: (ConnectedAccountDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -6215,6 +6439,30 @@ export interface DeleteSsoInput {identityProviderId: Scalars['UUID']}
export interface EditSsoInput {id: Scalars['UUID'],status: SSOIdentityProviderStatus}
export interface CreateMessageFolderInput {id?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),isSentFolder: Scalars['Boolean'],isSynced: Scalars['Boolean'],externalId?: (Scalars['String'] | null),pendingSyncAction: MessageFolderPendingSyncAction,messageChannelId: Scalars['UUID'],parentFolderId?: (Scalars['UUID'] | null)}
export interface UpdateMessageFolderInput {id: Scalars['UUID'],update: UpdateMessageFolderInputUpdates}
export interface UpdateMessageFolderInputUpdates {name?: (Scalars['String'] | null),syncCursor?: (Scalars['String'] | null),isSynced?: (Scalars['Boolean'] | null),pendingSyncAction?: (MessageFolderPendingSyncAction | null)}
export interface CreateCalendarChannelInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],visibility: CalendarChannelVisibility,syncStage: CalendarChannelSyncStage,connectedAccountId: Scalars['UUID'],isContactAutoCreationEnabled: Scalars['Boolean'],contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy,isSyncEnabled: Scalars['Boolean']}
export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateCalendarChannelInputUpdates}
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
export interface CreateMessageChannelInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],visibility: MessageChannelVisibility,type: MessageChannelType,syncStage: MessageChannelSyncStage,connectedAccountId: Scalars['UUID'],isContactAutoCreationEnabled: Scalars['Boolean'],contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy,messageFolderImportPolicy: MessageFolderImportPolicy,excludeNonProfessionalEmails: Scalars['Boolean'],excludeGroupEmails: Scalars['Boolean'],pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction,isSyncEnabled: Scalars['Boolean']}
export interface UpdateMessageChannelInput {id: Scalars['UUID'],update: UpdateMessageChannelInputUpdates}
export interface UpdateMessageChannelInputUpdates {visibility?: (MessageChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (MessageChannelContactAutoCreationPolicy | null),messageFolderImportPolicy?: (MessageFolderImportPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null),excludeNonProfessionalEmails?: (Scalars['Boolean'] | null),excludeGroupEmails?: (Scalars['Boolean'] | null)}
export interface CreateConnectedAccountInput {id?: (Scalars['UUID'] | null),handle: Scalars['String'],provider: Scalars['String'],accessToken?: (Scalars['String'] | null),refreshToken?: (Scalars['String'] | null),scopes?: (Scalars['String'][] | null),userWorkspaceId: Scalars['UUID']}
export interface UpdateConnectedAccountInput {id: Scalars['UUID'],update: UpdateConnectedAccountInputUpdates}
export interface UpdateConnectedAccountInputUpdates {accessToken?: (Scalars['String'] | null),refreshToken?: (Scalars['String'] | null),handleAliases?: (Scalars['String'][] | null),scopes?: (Scalars['String'][] | null)}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
@@ -7647,6 +7895,38 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const CalendarChannel_possibleTypes: string[] = ['CalendarChannel']
export const isCalendarChannel = (obj?: { __typename?: any } | null): obj is CalendarChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCalendarChannel"')
return CalendarChannel_possibleTypes.includes(obj.__typename)
}
const ConnectedAccountDTO_possibleTypes: string[] = ['ConnectedAccountDTO']
export const isConnectedAccountDTO = (obj?: { __typename?: any } | null): obj is ConnectedAccountDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConnectedAccountDTO"')
return ConnectedAccountDTO_possibleTypes.includes(obj.__typename)
}
const MessageChannel_possibleTypes: string[] = ['MessageChannel']
export const isMessageChannel = (obj?: { __typename?: any } | null): obj is MessageChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageChannel"')
return MessageChannel_possibleTypes.includes(obj.__typename)
}
const MessageFolder_possibleTypes: string[] = ['MessageFolder']
export const isMessageFolder = (obj?: { __typename?: any } | null): obj is MessageFolder => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMessageFolder"')
return MessageFolder_possibleTypes.includes(obj.__typename)
}
const CollectionHash_possibleTypes: string[] = ['CollectionHash']
export const isCollectionHash = (obj?: { __typename?: any } | null): obj is CollectionHash => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCollectionHash"')
@@ -8630,7 +8910,8 @@ export const enumFeatureFlagKey = {
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' as const,
IS_DRAFT_EMAIL_ENABLED: 'IS_DRAFT_EMAIL_ENABLED' as const,
IS_RICH_TEXT_V1_MIGRATED: 'IS_RICH_TEXT_V1_MIGRATED' as const,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const,
IS_CONNECTED_ACCOUNT_MIGRATED: 'IS_CONNECTED_ACCOUNT_MIGRATED' as const
}
export const enumRelationType = {
@@ -8733,6 +9014,89 @@ export const enumCommandMenuItemAvailabilityType = {
FALLBACK: 'FALLBACK' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
ACTIVE: 'ACTIVE' as const,
FAILED_INSUFFICIENT_PERMISSIONS: 'FAILED_INSUFFICIENT_PERMISSIONS' as const,
FAILED_UNKNOWN: 'FAILED_UNKNOWN' as const
}
export const enumCalendarChannelSyncStage = {
PENDING_CONFIGURATION: 'PENDING_CONFIGURATION' as const,
CALENDAR_EVENT_LIST_FETCH_PENDING: 'CALENDAR_EVENT_LIST_FETCH_PENDING' as const,
CALENDAR_EVENT_LIST_FETCH_SCHEDULED: 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED' as const,
CALENDAR_EVENT_LIST_FETCH_ONGOING: 'CALENDAR_EVENT_LIST_FETCH_ONGOING' as const,
CALENDAR_EVENTS_IMPORT_PENDING: 'CALENDAR_EVENTS_IMPORT_PENDING' as const,
CALENDAR_EVENTS_IMPORT_SCHEDULED: 'CALENDAR_EVENTS_IMPORT_SCHEDULED' as const,
CALENDAR_EVENTS_IMPORT_ONGOING: 'CALENDAR_EVENTS_IMPORT_ONGOING' as const,
FAILED: 'FAILED' as const
}
export const enumCalendarChannelVisibility = {
METADATA: 'METADATA' as const,
SHARE_EVERYTHING: 'SHARE_EVERYTHING' as const
}
export const enumCalendarChannelContactAutoCreationPolicy = {
AS_PARTICIPANT_AND_ORGANIZER: 'AS_PARTICIPANT_AND_ORGANIZER' as const,
AS_PARTICIPANT: 'AS_PARTICIPANT' as const,
AS_ORGANIZER: 'AS_ORGANIZER' as const,
NONE: 'NONE' as const
}
export const enumMessageChannelVisibility = {
METADATA: 'METADATA' as const,
SUBJECT: 'SUBJECT' as const,
SHARE_EVERYTHING: 'SHARE_EVERYTHING' as const
}
export const enumMessageChannelType = {
EMAIL: 'EMAIL' as const,
SMS: 'SMS' as const
}
export const enumMessageChannelContactAutoCreationPolicy = {
SENT_AND_RECEIVED: 'SENT_AND_RECEIVED' as const,
SENT: 'SENT' as const,
NONE: 'NONE' as const
}
export const enumMessageFolderImportPolicy = {
ALL_FOLDERS: 'ALL_FOLDERS' as const,
SELECTED_FOLDERS: 'SELECTED_FOLDERS' as const
}
export const enumMessageChannelPendingGroupEmailsAction = {
GROUP_EMAILS_DELETION: 'GROUP_EMAILS_DELETION' as const,
GROUP_EMAILS_IMPORT: 'GROUP_EMAILS_IMPORT' as const,
NONE: 'NONE' as const
}
export const enumMessageChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
ACTIVE: 'ACTIVE' as const,
FAILED_INSUFFICIENT_PERMISSIONS: 'FAILED_INSUFFICIENT_PERMISSIONS' as const,
FAILED_UNKNOWN: 'FAILED_UNKNOWN' as const
}
export const enumMessageChannelSyncStage = {
PENDING_CONFIGURATION: 'PENDING_CONFIGURATION' as const,
MESSAGE_LIST_FETCH_PENDING: 'MESSAGE_LIST_FETCH_PENDING' as const,
MESSAGE_LIST_FETCH_SCHEDULED: 'MESSAGE_LIST_FETCH_SCHEDULED' as const,
MESSAGE_LIST_FETCH_ONGOING: 'MESSAGE_LIST_FETCH_ONGOING' as const,
MESSAGES_IMPORT_PENDING: 'MESSAGES_IMPORT_PENDING' as const,
MESSAGES_IMPORT_SCHEDULED: 'MESSAGES_IMPORT_SCHEDULED' as const,
MESSAGES_IMPORT_ONGOING: 'MESSAGES_IMPORT_ONGOING' as const,
FAILED: 'FAILED' as const
}
export const enumMessageFolderPendingSyncAction = {
FOLDER_DELETION: 'FOLDER_DELETION' as const,
NONE: 'NONE' as const
}
export const enumAllMetadataName = {
fieldMetadata: 'fieldMetadata' as const,
objectMetadata: 'objectMetadata' as const,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Command({
name: 'upgrade:1-20:migrate-messaging-infrastructure-to-metadata',
description:
'Backfill connectedAccount, messageChannel, calendarChannel, and messageFolder to core metadata schema',
})
export class MigrateMessagingInfrastructureToMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
@InjectRepository(MessageFolderEntity)
private readonly messageFolderRepository: Repository<MessageFolderEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isAlreadyMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
if (isAlreadyMigrated) {
this.logger.log(
`IS_CONNECTED_ACCOUNT_MIGRATED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
const connectedAccountWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const messageChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const calendarChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageFolderWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
const connectedAccounts = await connectedAccountWorkspaceRepository.find();
const messageChannels = await messageChannelWorkspaceRepository.find();
const calendarChannels = await calendarChannelWorkspaceRepository.find();
const messageFolders = await messageFolderWorkspaceRepository.find();
const workspaceMemberIdToUserWorkspaceIdMap =
await this.buildWorkspaceMemberIdToUserWorkspaceIdMap(workspaceId);
const connectedAccountsWithMissingHandle = connectedAccounts.filter(
(account) => !account.handle,
);
const connectedAccountsWithUnresolvedOwner = connectedAccounts.filter(
(account) =>
!workspaceMemberIdToUserWorkspaceIdMap.has(account.accountOwnerId),
);
const messageChannelsWithMissingHandle = messageChannels.filter(
(channel) => !channel.handle,
);
const calendarChannelsWithMissingHandle = calendarChannels.filter(
(channel) => !channel.handle,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Workspace ${workspaceId}: ` +
`${connectedAccounts.length} connected accounts, ` +
`${messageChannels.length} message channels, ` +
`${calendarChannels.length} calendar channels, ` +
`${messageFolders.length} message folders`,
);
if (connectedAccountsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithMissingHandle.length} connected accounts have empty handle`,
);
}
if (connectedAccountsWithUnresolvedOwner.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithUnresolvedOwner.length} connected accounts have unresolvable accountOwnerId (no matching userWorkspace)`,
);
}
if (messageChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${messageChannelsWithMissingHandle.length} message channels have empty handle`,
);
}
if (calendarChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${calendarChannelsWithMissingHandle.length} calendar channels have empty handle`,
);
}
return;
}
let migratedConnectedAccountIds = new Set(
connectedAccounts.map((account) => account.id),
);
let migratedMessageChannelIds = new Set(
messageChannels.map((channel) => channel.id),
);
if (connectedAccounts.length > 0) {
const coreConnectedAccounts = connectedAccounts
.filter((workspaceEntity) => {
const userWorkspaceId = workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
);
if (!userWorkspaceId) {
this.logger.warn(
`Skipping connected account ${workspaceEntity.id}: no userWorkspace found for workspaceMember ${workspaceEntity.accountOwnerId}`,
);
return false;
}
return true;
})
.map((workspaceEntity) => {
const handleAliases = isNonEmptyString(workspaceEntity.handleAliases)
? workspaceEntity.handleAliases
.split(',')
.map((alias) => alias.trim())
: null;
return {
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
provider: workspaceEntity.provider,
accessToken: workspaceEntity.accessToken,
refreshToken: workspaceEntity.refreshToken,
lastCredentialsRefreshedAt:
workspaceEntity.lastCredentialsRefreshedAt,
authFailedAt: workspaceEntity.authFailedAt,
handleAliases,
scopes: workspaceEntity.scopes,
connectionParameters:
workspaceEntity.connectionParameters as Record<
string,
unknown
> | null,
userWorkspaceId: workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
)!,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
};
});
if (coreConnectedAccounts.length > 0) {
await this.connectedAccountRepository.save(
coreConnectedAccounts as unknown as ConnectedAccountEntity[],
);
this.logger.log(
`Migrated ${coreConnectedAccounts.length} connected accounts for workspace ${workspaceId}`,
);
}
migratedConnectedAccountIds = new Set(
coreConnectedAccounts.map((account) => account.id),
);
}
if (messageChannels.length > 0) {
const coreMessageChannels = messageChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
visibility: workspaceEntity.visibility,
handle: workspaceEntity.handle ?? '',
type: workspaceEntity.type,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
messageFolderImportPolicy: workspaceEntity.messageFolderImportPolicy,
excludeNonProfessionalEmails:
workspaceEntity.excludeNonProfessionalEmails,
excludeGroupEmails: workspaceEntity.excludeGroupEmails,
pendingGroupEmailsAction: workspaceEntity.pendingGroupEmailsAction,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
throttleRetryAfter: workspaceEntity.throttleRetryAfter
? new Date(workspaceEntity.throttleRetryAfter)
: null,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
if (coreMessageChannels.length > 0) {
await this.messageChannelRepository.save(
coreMessageChannels as unknown as MessageChannelEntity[],
);
this.logger.log(
`Migrated ${coreMessageChannels.length} message channels for workspace ${workspaceId}`,
);
}
migratedMessageChannelIds = new Set(
coreMessageChannels.map((channel) => channel.id),
);
}
if (calendarChannels.length > 0) {
const coreCalendarChannels = calendarChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
visibility: workspaceEntity.visibility,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
await this.calendarChannelRepository.save(
coreCalendarChannels as unknown as CalendarChannelEntity[],
);
this.logger.log(
`Migrated ${coreCalendarChannels.length} calendar channels for workspace ${workspaceId}`,
);
}
if (messageFolders.length > 0) {
const coreMessageFolders = messageFolders
.filter((workspaceEntity) =>
migratedMessageChannelIds.has(workspaceEntity.messageChannelId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
name: workspaceEntity.name,
syncCursor: workspaceEntity.syncCursor,
isSentFolder: workspaceEntity.isSentFolder,
isSynced: workspaceEntity.isSynced,
parentFolderId: isNonEmptyString(workspaceEntity.parentFolderId)
? workspaceEntity.parentFolderId
: null,
externalId: workspaceEntity.externalId,
pendingSyncAction: workspaceEntity.pendingSyncAction,
messageChannelId: workspaceEntity.messageChannelId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
await this.messageFolderRepository.save(
coreMessageFolders as unknown as MessageFolderEntity[],
);
this.logger.log(
`Migrated ${coreMessageFolders.length} message folders for workspace ${workspaceId}`,
);
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED],
workspaceId,
);
this.logger.log(
`Enabled IS_CONNECTED_ACCOUNT_MIGRATED for workspace ${workspaceId}`,
);
}
private async buildWorkspaceMemberIdToUserWorkspaceIdMap(
workspaceId: string,
): Promise<Map<string, string>> {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers = await workspaceMemberRepository.find();
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { workspaceId },
select: ['id', 'userId'],
});
const userWorkspaceIdByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace.id,
]),
);
return new Map(
workspaceMembers
.filter((member) => userWorkspaceIdByUserId.has(member.userId))
.map((member) => [
member.id,
userWorkspaceIdByUserId.get(member.userId)!,
]),
);
}
}
@@ -8,13 +8,19 @@ import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/u
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -23,7 +29,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
TypeOrmModule.forFeature([
WorkspaceEntity,
ConnectedAccountEntity,
MessageChannelEntity,
CalendarChannelEntity,
MessageFolderEntity,
UserWorkspaceEntity,
]),
DataSourceModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
@@ -44,6 +57,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
exports: [
IdentifyPermissionFlagMetadataCommand,
@@ -55,6 +69,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
})
export class V1_20_UpgradeVersionCommandModule {}
@@ -40,6 +40,7 @@ import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/u
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -101,6 +102,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand,
protected readonly migrateMessagingInfrastructureToMetadataCommand: MigrateMessagingInfrastructureToMetadataCommand,
) {
super(
workspaceRepository,
@@ -159,6 +161,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.backfillCommandMenuItemsCommand,
this.backfillPageLayoutsCommand,
this.seedCliApplicationRegistrationCommand,
this.migrateMessagingInfrastructureToMetadataCommand,
];
this.allCommands = {
@@ -0,0 +1,139 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMessagingInfrastructureMetadataEntities1773945207801
implements MigrationInterface
{
name = 'AddMessagingInfrastructureMetadataEntities1773945207801';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."connectedAccount" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "provider" character varying NOT NULL, "accessToken" character varying, "refreshToken" character varying, "lastCredentialsRefreshedAt" TIMESTAMP WITH TIME ZONE, "authFailedAt" TIMESTAMP WITH TIME ZONE, "handleAliases" character varying array, "scopes" character varying array, "connectionParameters" jsonb, "lastSignedInAt" TIMESTAMP WITH TIME ZONE, "oidcTokenClaims" jsonb, "userWorkspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8e7a0a0bbc2e06ac2acf89b7f3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_visibility_enum" AS ENUM('METADATA', 'SUBJECT', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_type_enum" AS ENUM('EMAIL', 'SMS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_contactautocreationpolicy_enum" AS ENUM('SENT_AND_RECEIVED', 'SENT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_messagefolderimportpolicy_enum" AS ENUM('ALL_FOLDERS', 'SELECTED_FOLDERS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_pendinggroupemailsaction_enum" AS ENUM('GROUP_EMAILS_DELETION', 'GROUP_EMAILS_IMPORT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "visibility" "core"."messageChannel_visibility_enum" NOT NULL, "handle" character varying NOT NULL, "type" "core"."messageChannel_type_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."messageChannel_contactautocreationpolicy_enum" NOT NULL, "messageFolderImportPolicy" "core"."messageChannel_messagefolderimportpolicy_enum" NOT NULL, "excludeNonProfessionalEmails" boolean NOT NULL, "excludeGroupEmails" boolean NOT NULL, "pendingGroupEmailsAction" "core"."messageChannel_pendinggroupemailsaction_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStatus" "core"."messageChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."messageChannel_syncstage_enum" NOT NULL, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "throttleRetryAfter" TIMESTAMP WITH TIME ZONE, "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_438b9412475f39712ed065f77af" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageFolder_pendingsyncaction_enum" AS ENUM('FOLDER_DELETION', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageFolder" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "syncCursor" character varying, "isSentFolder" boolean NOT NULL, "isSynced" boolean NOT NULL, "parentFolderId" uuid, "externalId" character varying, "pendingSyncAction" "core"."messageFolder_pendingsyncaction_enum" NOT NULL, "messageChannelId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_85cb5a339d9f7f1106dde9e4db8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'CALENDAR_EVENT_LIST_FETCH_PENDING', 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED', 'CALENDAR_EVENT_LIST_FETCH_ONGOING', 'CALENDAR_EVENTS_IMPORT_PENDING', 'CALENDAR_EVENTS_IMPORT_SCHEDULED', 'CALENDAR_EVENTS_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_visibility_enum" AS ENUM('METADATA', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_contactautocreationpolicy_enum" AS ENUM('AS_PARTICIPANT_AND_ORGANIZER', 'AS_PARTICIPANT', 'AS_ORGANIZER', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."calendarChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "syncStatus" "core"."calendarChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."calendarChannel_syncstage_enum" NOT NULL, "visibility" "core"."calendarChannel_visibility_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."calendarChannel_contactautocreationpolicy_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_796d701c0c35518517d0f3e0e0b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "FK_1c7af038a011e99c27044793c6a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_2e966cbb240771c67630d52895c" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe" FOREIGN KEY ("messageChannelId") REFERENCES "core"."messageChannel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_c7bc368c97a18a072413d67cf45" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_c7bc368c97a18a072413d67cf45"`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_2e966cbb240771c67630d52895c"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_1c7af038a011e99c27044793c6a"`,
);
await queryRunner.query(`DROP TABLE "core"."calendarChannel"`);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_visibility_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstage_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstatus_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageFolder"`);
await queryRunner.query(
`DROP TYPE "core"."messageFolder_pendingsyncaction_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageChannel"`);
await queryRunner.query(`DROP TYPE "core"."messageChannel_syncstage_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_syncstatus_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_pendinggroupemailsaction_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_messagefolderimportpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(`DROP TYPE "core"."messageChannel_type_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_visibility_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."connectedAccount"`);
}
}
@@ -59,6 +59,9 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -104,6 +107,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ConnectedAccountModule,
MessagingCommonModule,
MessagingFolderSyncManagerModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MessageChannelDataAccessModule,
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { v4 } from 'uuid';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -9,7 +10,6 @@ import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CreateCalendarChannelInput = {
@@ -25,6 +25,7 @@ export type CreateCalendarChannelInput = {
export class CreateCalendarChannelService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async createCalendarChannel(
@@ -43,15 +44,12 @@ export class CreateCalendarChannelService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const newCalendarChannelId = v4();
const newCalendarChannel = await calendarChannelRepository.save(
await this.calendarChannelDataAccessService.save(
workspaceId,
{
id: v4(),
id: newCalendarChannelId,
connectedAccountId,
handle,
visibility:
@@ -63,11 +61,10 @@ export class CreateCalendarChannelService {
? CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING
: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
{},
manager,
);
return newCalendarChannel.id;
return newCalendarChannelId;
},
authContext,
);
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type CreateConnectedAccountInput = {
workspaceId: string;
@@ -23,6 +23,7 @@ export type CreateConnectedAccountInput = {
export class CreateConnectedAccountService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async createConnectedAccount(
@@ -43,13 +44,8 @@ export class CreateConnectedAccountService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.save(
await this.connectedAccountDataAccessService.save(
workspaceId,
{
id: connectedAccountId,
handle,
@@ -59,7 +55,6 @@ export class CreateConnectedAccountService {
accountOwnerId,
scopes,
},
{},
manager,
);
}, authContext);
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -29,6 +30,7 @@ export type CreateMessageChannelInput = {
export class CreateMessageChannelService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly syncMessageFoldersService: SyncMessageFoldersService,
) {}
@@ -48,15 +50,10 @@ export class CreateMessageChannelService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const newMessageChannelId = v4();
await messageChannelRepository.insert(
await this.messageChannelDataAccessService.save(
workspaceId,
{
id: newMessageChannelId,
connectedAccountId,
@@ -76,17 +73,19 @@ export class CreateMessageChannelService {
manager,
);
const createdMessageChannel = await messageChannelRepository.findOne({
where: { id: newMessageChannelId },
relations: ['connectedAccount', 'messageFolders'],
});
const createdMessageChannel =
await this.messageChannelDataAccessService.findOne(workspaceId, {
where: { id: newMessageChannelId },
relations: ['connectedAccount', 'messageFolders'],
});
if (!isDefined(createdMessageChannel)) {
throw new Error('Message channel not found');
}
await this.syncMessageFoldersService.syncMessageFolders({
messageChannel: createdMessageChannel,
messageChannel:
createdMessageChannel as unknown as MessageChannelWorkspaceEntity,
workspaceId,
});
@@ -11,6 +11,9 @@ import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/au
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -36,22 +39,16 @@ describe('GoogleAPIsService', () => {
let messagingChannelSyncStatusService: MessageChannelSyncStatusService;
let createMessageChannelService: CreateMessageChannelService;
const mockConnectedAccountRepository = {
const mockConnectedAccountDataAccessService = {
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockCalendarChannelRepository = {
findOne: jest.fn(),
const mockMessageChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockMessageChannelRepository = {
findOne: jest.fn(),
const mockCalendarChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockWorkspaceMemberRepository = {
@@ -84,12 +81,6 @@ describe('GoogleAPIsService', () => {
getRepository: jest
.fn()
.mockImplementation((_workspaceId, entity) => {
if (entity === 'connectedAccount')
return mockConnectedAccountRepository;
if (entity === 'calendarChannel')
return mockCalendarChannelRepository;
if (entity === 'messageChannel')
return mockMessageChannelRepository;
if (entity === 'workspaceMember')
return mockWorkspaceMemberRepository;
@@ -188,6 +179,18 @@ describe('GoogleAPIsService', () => {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: MessageChannelDataAccessService,
useValue: mockMessageChannelDataAccessService,
},
{
provide: CalendarChannelDataAccessService,
useValue: mockCalendarChannelDataAccessService,
},
],
}).compile();
@@ -221,7 +224,7 @@ describe('GoogleAPIsService', () => {
provider: ConnectedAccountProvider.GOOGLE,
} as ConnectedAccountWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValue(
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(
existingConnectedAccount,
);
@@ -237,11 +240,11 @@ describe('GoogleAPIsService', () => {
syncStage: CalendarChannelSyncStage.FAILED,
};
mockCalendarChannelRepository.find.mockResolvedValue([
mockCalendarChannelDataAccessService.find.mockResolvedValue([
failedCalendarChannel,
]);
mockMessageChannelRepository.find.mockResolvedValue([]);
mockMessageChannelDataAccessService.find.mockResolvedValue([]);
await service.refreshGoogleRefreshToken({
handle: 'test@example.com',
@@ -18,6 +18,9 @@ import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decora
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';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -29,16 +32,14 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import {
CalendarChannelSyncStage,
type CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
MessageChannelSyncStatus,
type MessageChannelVisibility,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
@@ -65,6 +66,9 @@ export class GoogleAPIsService {
private readonly googleAPIScopesService: GoogleAPIScopesService,
private readonly googleApisServiceAvailabilityService: GoogleApisServiceAvailabilityService,
private readonly featureFlagService: FeatureFlagService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async refreshGoogleRefreshToken(input: {
@@ -128,31 +132,14 @@ export class GoogleAPIsService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { handle, accountOwnerId: workspaceMemberId },
});
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { handle, accountOwnerId: workspaceMemberId },
});
const existingAccountId = connectedAccount?.id;
const newOrExistingConnectedAccountId = existingAccountId ?? v4();
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
@@ -200,7 +187,6 @@ export class GoogleAPIsService {
accessToken: input.accessToken,
refreshToken: input.refreshToken,
scopes,
connectedAccount,
manager,
},
);
@@ -229,9 +215,10 @@ export class GoogleAPIsService {
);
if (isMessagingEnabled) {
const messageChannels = await messageChannelRepository.find({
where: { connectedAccountId: newOrExistingConnectedAccountId },
});
const messageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
});
if (!isMessagingAvailable && messageChannels.length > 0) {
await this.messagingChannelSyncStatusService.markAsFailed(
@@ -261,9 +248,12 @@ export class GoogleAPIsService {
}
if (isCalendarEnabled) {
const calendarChannels = await calendarChannelRepository.find({
where: { connectedAccountId: newOrExistingConnectedAccountId },
});
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (!isCalendarAvailable && calendarChannels.length > 0) {
await this.calendarChannelSyncStatusService.markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport(
@@ -8,6 +8,9 @@ import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/serv
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -33,22 +36,16 @@ describe('MicrosoftAPIsService', () => {
let calendarChannelSyncStatusService: CalendarChannelSyncStatusService;
let createMessageChannelService: CreateMessageChannelService;
const mockConnectedAccountRepository = {
const mockConnectedAccountDataAccessService = {
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockCalendarChannelRepository = {
findOne: jest.fn(),
const mockMessageChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockMessageChannelRepository = {
findOne: jest.fn(),
const mockCalendarChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockWorkspaceMemberRepository = {
@@ -81,12 +78,6 @@ describe('MicrosoftAPIsService', () => {
getRepository: jest
.fn()
.mockImplementation((_workspaceId, entity) => {
if (entity === 'connectedAccount')
return mockConnectedAccountRepository;
if (entity === 'calendarChannel')
return mockCalendarChannelRepository;
if (entity === 'messageChannel')
return mockMessageChannelRepository;
if (entity === 'workspaceMember')
return mockWorkspaceMemberRepository;
@@ -162,6 +153,18 @@ describe('MicrosoftAPIsService', () => {
provide: getQueueToken(MessageQueue.calendarQueue),
useValue: mockCalendarQueueService,
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: MessageChannelDataAccessService,
useValue: mockMessageChannelDataAccessService,
},
{
provide: CalendarChannelDataAccessService,
useValue: mockCalendarChannelDataAccessService,
},
],
}).compile();
@@ -195,7 +198,7 @@ describe('MicrosoftAPIsService', () => {
provider: ConnectedAccountProvider.MICROSOFT,
} as ConnectedAccountWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValue(
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(
existingConnectedAccount,
);
@@ -211,11 +214,11 @@ describe('MicrosoftAPIsService', () => {
syncStage: CalendarChannelSyncStage.FAILED,
};
mockCalendarChannelRepository.find.mockResolvedValue([
mockCalendarChannelDataAccessService.find.mockResolvedValue([
failedCalendarChannel,
]);
mockMessageChannelRepository.find.mockResolvedValue([
mockMessageChannelDataAccessService.find.mockResolvedValue([
{
id: 'message-channel-id',
connectedAccountId: 'existing-account-id',
@@ -12,6 +12,9 @@ import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decora
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';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -23,15 +26,13 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import {
CalendarChannelSyncStage,
type CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
type MessageChannelVisibility,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
@@ -55,6 +56,9 @@ export class MicrosoftAPIsService {
private readonly createConnectedAccountService: CreateConnectedAccountService,
private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService,
private readonly twentyConfigService: TwentyConfigService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async refreshMicrosoftRefreshToken(input: {
@@ -82,31 +86,14 @@ export class MicrosoftAPIsService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { handle, accountOwnerId: workspaceMemberId },
});
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { handle, accountOwnerId: workspaceMemberId },
});
const existingAccountId = connectedAccount?.id;
const newOrExistingConnectedAccountId = existingAccountId ?? v4();
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
@@ -156,7 +143,6 @@ export class MicrosoftAPIsService {
accessToken: input.accessToken,
refreshToken: input.refreshToken,
scopes,
connectedAccount,
manager,
},
);
@@ -197,11 +183,10 @@ export class MicrosoftAPIsService {
if (
this.twentyConfigService.get('MESSAGING_PROVIDER_MICROSOFT_ENABLED')
) {
const messageChannels = await messageChannelRepository.find({
where: {
const messageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
});
for (const messageChannel of messageChannels) {
if (
@@ -222,11 +207,12 @@ export class MicrosoftAPIsService {
if (
this.twentyConfigService.get('CALENDAR_PROVIDER_MICROSOFT_ENABLED')
) {
const calendarChannels = await calendarChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
for (const calendarChannel of calendarChannels) {
if (
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type UpdateConnectedAccountOnReconnectInput = {
workspaceId: string;
@@ -11,7 +11,6 @@ export type UpdateConnectedAccountOnReconnectInput = {
accessToken: string;
refreshToken: string;
scopes: string[];
connectedAccount: ConnectedAccountWorkspaceEntity;
manager: WorkspaceEntityManager;
};
@@ -19,6 +18,7 @@ export type UpdateConnectedAccountOnReconnectInput = {
export class UpdateConnectedAccountOnReconnectService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async updateConnectedAccountOnReconnect(
@@ -30,19 +30,13 @@ export class UpdateConnectedAccountOnReconnectService {
accessToken,
refreshToken,
scopes,
manager,
} = input;
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{
id: connectedAccountId,
},
@@ -52,7 +46,6 @@ export class UpdateConnectedAccountOnReconnectService {
scopes,
authFailedAt: null,
},
manager,
);
}, authContext);
}
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ImapSmtpCaldavValidatorModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@@ -21,6 +22,7 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se
IMAPAPIsModule,
MessagingImportManagerModule,
MessageQueueModule,
ConnectedAccountDataAccessModule,
TwentyORMModule,
FeatureFlagModule,
ImapSmtpCaldavValidatorModule,
@@ -6,6 +6,7 @@ import { createTransport } from 'nodemailer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import {
type AccountType,
type ConnectionParameters,
@@ -21,6 +22,7 @@ export class ImapSmtpCaldavService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async testImapConnection(
@@ -188,20 +190,15 @@ export class ImapSmtpCaldavService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: {
id: connectionId,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
},
});
const connectedAccount = await connectedAccountRepository.findOne({
where: {
id: connectionId,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
},
});
return connectedAccount;
return connectedAccount as ConnectedAccountWorkspaceEntity | null;
},
authContext,
);
@@ -15,6 +15,7 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -35,6 +36,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
ViewModule,
NavigationMenuItemModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ConnectedAccountDataAccessModule,
],
providers: [
HttpTool,
@@ -20,11 +20,12 @@ import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-to
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
@@ -33,6 +34,7 @@ export class EmailComposerService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@@ -54,20 +56,15 @@ export class EmailComposerService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { id: connectedAccountId },
relations: {
messageChannels: {
messageFolders: true,
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: connectedAccountId },
relations: {
messageChannels: {
messageFolders: true,
},
},
},
});
});
if (!isDefined(connectedAccount)) {
throw new EmailToolException(
@@ -76,7 +73,7 @@ export class EmailComposerService {
);
}
return connectedAccount;
return connectedAccount as unknown as ConnectedAccountWorkspaceEntity;
},
authContext,
);
@@ -89,12 +86,8 @@ export class EmailComposerService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const allAccounts = await connectedAccountRepository.find();
const allAccounts =
await this.connectedAccountDataAccessService.find(workspaceId);
if (!allAccounts || allAccounts.length === 0) {
throw new EmailToolException(
@@ -291,7 +284,7 @@ export class EmailComposerService {
...connectedAccount,
accessToken,
refreshToken,
};
} as unknown as ConnectedAccountWorkspaceEntity;
const attachments = await this.getAttachments(files || [], workspaceId);
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
import { CalendarChannelResolver } from 'src/engine/metadata-modules/calendar-channel/resolvers/calendar-channel.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([CalendarChannelEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
CalendarChannelMetadataService,
CalendarChannelResolver,
CalendarChannelGraphqlApiExceptionInterceptor,
],
exports: [CalendarChannelMetadataService],
})
export class CalendarChannelMetadataModule {}
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
CalendarChannelSyncStage,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@Injectable()
export class CalendarChannelMetadataService {
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly repository: Repository<CalendarChannelEntity>,
) {}
async findAll(workspaceId: string): Promise<CalendarChannelDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByConnectedAccountId(
connectedAccountId: string,
workspaceId: string,
): Promise<CalendarChannelDTO[]> {
return this.repository.find({
where: { connectedAccountId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<CalendarChannelDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<CalendarChannelEntity> & {
workspaceId: string;
handle: string;
connectedAccountId: string;
visibility: CalendarChannelVisibility;
syncStage: CalendarChannelSyncStage;
},
): Promise<CalendarChannelDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<CalendarChannelEntity>,
): Promise<CalendarChannelDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
);
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<CalendarChannelDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -0,0 +1,37 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum CalendarChannelExceptionCode {
CALENDAR_CHANNEL_NOT_FOUND = 'CALENDAR_CHANNEL_NOT_FOUND',
INVALID_CALENDAR_CHANNEL_INPUT = 'INVALID_CALENDAR_CHANNEL_INPUT',
}
const getCalendarChannelExceptionUserFriendlyMessage = (
code: CalendarChannelExceptionCode,
) => {
switch (code) {
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND:
return msg`Calendar channel not found.`;
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
return msg`Invalid calendar channel input.`;
default:
assertUnreachable(code);
}
};
export class CalendarChannelException extends CustomException<CalendarChannelExceptionCode> {
constructor(
message: string,
code: CalendarChannelExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getCalendarChannelExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
@Module({
imports: [
TypeOrmModule.forFeature([CalendarChannelEntity]),
FeatureFlagModule,
ConnectedAccountDataAccessModule,
],
providers: [CalendarChannelDataAccessService],
exports: [CalendarChannelDataAccessService],
})
export class CalendarChannelDataAccessModule {}
@@ -0,0 +1,280 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
@Injectable()
export class CalendarChannelDataAccessService {
private readonly logger = new Logger(CalendarChannelDataAccessService.name);
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly coreRepository: Repository<CalendarChannelEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
private async toCoreWhere(
workspaceId: string,
where: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const coreWhere: Record<string, unknown> = { ...where, workspaceId };
if (
coreWhere.connectedAccount &&
typeof coreWhere.connectedAccount === 'object'
) {
const connectedAccountWhere = {
...(coreWhere.connectedAccount as Record<string, unknown>),
};
if ('accountOwnerId' in connectedAccountWhere) {
const { accountOwnerId, ...restConnectedAccount } =
connectedAccountWhere;
const resolvedConnectedAccounts =
await this.connectedAccountDataAccessService.find(workspaceId, {
accountOwnerId,
} as never);
if (resolvedConnectedAccounts.length > 0) {
coreWhere.connectedAccountId = resolvedConnectedAccounts[0].id;
} else {
coreWhere.connectedAccountId = '00000000-0000-0000-0000-000000000000';
}
if (Object.keys(restConnectedAccount).length > 0) {
coreWhere.connectedAccount = restConnectedAccount;
} else {
delete coreWhere.connectedAccount;
}
}
}
return coreWhere;
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity | null> {
if (await this.isMigrated(workspaceId)) {
const where = options.where as Record<string, unknown>;
const coreWhere = Array.isArray(where)
? await Promise.all(
where.map((whereItem: Record<string, unknown>) =>
this.toCoreWhere(workspaceId, whereItem),
),
)
: await this.toCoreWhere(workspaceId, where);
return this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<CalendarChannelEntity>) as unknown as Promise<CalendarChannelWorkspaceEntity | null>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
whereOrOptions?:
| FindOptionsWhere<CalendarChannelWorkspaceEntity>
| FindManyOptions<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity[]> {
if (
whereOrOptions !== undefined &&
typeof whereOrOptions === 'object' &&
whereOrOptions !== null &&
!Array.isArray(whereOrOptions) &&
'where' in whereOrOptions
) {
const options =
whereOrOptions as FindManyOptions<CalendarChannelWorkspaceEntity>;
if (await this.isMigrated(workspaceId)) {
const { where } = options;
const coreWhere = Array.isArray(where)
? await Promise.all(
where.map((whereItem) =>
this.toCoreWhere(
workspaceId,
whereItem as Record<string, unknown>,
),
),
)
: await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
return this.coreRepository.find({
...options,
where: coreWhere,
} as FindManyOptions<CalendarChannelEntity>) as unknown as Promise<
CalendarChannelWorkspaceEntity[]
>;
}
const workspaceRepository =
await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find(options);
}
const where = whereOrOptions as
| FindOptionsWhere<CalendarChannelWorkspaceEntity>
| undefined;
if (await this.isMigrated(workspaceId)) {
const coreWhere = where
? await this.toCoreWhere(workspaceId, where as Record<string, unknown>)
: { workspaceId };
return this.coreRepository.find({
where: coreWhere,
} as FindManyOptions<CalendarChannelEntity>) as unknown as Promise<
CalendarChannelWorkspaceEntity[]
>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<CalendarChannelWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data, {}, manager);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.save({
...data,
workspaceId,
} as unknown as CalendarChannelEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
data: Partial<CalendarChannelWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.update(
{ ...where, workspaceId } as FindOptionsWhere<CalendarChannelEntity>,
data as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel update to core: ${error}`,
);
throw error;
}
}
}
async increment(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
propertyPath: string,
value: number,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.increment(where, propertyPath, value, undefined, [
propertyPath,
'id',
]);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.increment(
{
...where,
workspaceId,
} as FindOptionsWhere<CalendarChannelEntity>,
propertyPath,
value,
);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel increment to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.delete(where);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.delete({
...where,
workspaceId,
} as FindOptionsWhere<CalendarChannelEntity>);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,96 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('CalendarChannel')
export class CalendarChannelDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(CalendarChannelSyncStatus)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStatus)
syncStatus: CalendarChannelSyncStatus;
@IsEnum(CalendarChannelSyncStage)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStage)
syncStage: CalendarChannelSyncStage;
@IsEnum(CalendarChannelVisibility)
@IsNotEmpty()
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
@IsBoolean()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => CalendarChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@IsBoolean()
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
syncCursor: string | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncedAt: Date | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncStageStartedAt: Date | null;
@IsInt()
@Field()
throttleFailureCount: number;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,60 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateCalendarChannelInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(CalendarChannelVisibility)
@IsNotEmpty()
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
@IsEnum(CalendarChannelSyncStage)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStage)
syncStage: CalendarChannelSyncStage;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => CalendarChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@IsBoolean()
@IsNotEmpty()
@Field()
isSyncEnabled: boolean;
}
@@ -0,0 +1,53 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateCalendarChannelInputUpdates {
@IsOptional()
@IsEnum(CalendarChannelVisibility)
@Field(() => CalendarChannelVisibility, { nullable: true })
visibility?: CalendarChannelVisibility;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isContactAutoCreationEnabled?: boolean;
@IsOptional()
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@Field(() => CalendarChannelContactAutoCreationPolicy, { nullable: true })
contactAutoCreationPolicy?: CalendarChannelContactAutoCreationPolicy;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSyncEnabled?: boolean;
}
@InputType()
export class UpdateCalendarChannelInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateCalendarChannelInputUpdates)
@ValidateNested()
@Field(() => UpdateCalendarChannelInputUpdates)
update: UpdateCalendarChannelInputUpdates;
}
@@ -0,0 +1,93 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'calendarChannel', schema: 'core' })
export class CalendarChannelEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({
type: 'enum',
enum: CalendarChannelSyncStatus,
nullable: false,
default: CalendarChannelSyncStatus.NOT_SYNCED,
})
syncStatus: CalendarChannelSyncStatus;
@Column({
type: 'enum',
enum: CalendarChannelSyncStage,
nullable: false,
})
syncStage: CalendarChannelSyncStage;
@Column({
type: 'enum',
enum: CalendarChannelVisibility,
nullable: false,
})
visibility: CalendarChannelVisibility;
@Column({ type: 'boolean', nullable: false })
isContactAutoCreationEnabled: boolean;
@Column({
type: 'enum',
enum: CalendarChannelContactAutoCreationPolicy,
nullable: false,
})
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@Column({ type: 'boolean', nullable: false })
isSyncEnabled: boolean;
@Column({ type: 'varchar', nullable: true })
syncCursor: string | null;
@Column({ type: 'timestamptz', nullable: true })
syncedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
syncStageStartedAt: Date | null;
@Column({ type: 'integer', nullable: false, default: 0 })
throttleFailureCount: number;
@Column({ type: 'uuid', nullable: false })
connectedAccountId: string;
@ManyToOne(
() => ConnectedAccountEntity,
(connectedAccount) => connectedAccount.calendarChannels,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'connectedAccountId' })
connectedAccount: Relation<ConnectedAccountEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { calendarChannelGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/calendar-channel/utils/calendar-channel-graphql-api-exception-handler.util';
@Injectable()
export class CalendarChannelGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(calendarChannelGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,98 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CreateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/create-calendar-channel.input';
import { UpdateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/update-calendar-channel.input';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(CalendarChannelGraphqlApiExceptionInterceptor)
@MetadataResolver(() => CalendarChannelDTO)
export class CalendarChannelResolver {
constructor(
private readonly calendarChannelMetadataService: CalendarChannelMetadataService,
) {}
@Query(() => [CalendarChannelDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannels(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('connectedAccountId', {
type: () => UUIDScalarType,
nullable: true,
})
connectedAccountId?: string,
): Promise<CalendarChannelDTO[]> {
if (connectedAccountId) {
return this.calendarChannelMetadataService.findByConnectedAccountId(
connectedAccountId,
workspace.id,
);
}
return this.calendarChannelMetadataService.findAll(workspace.id);
}
@Query(() => CalendarChannelDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO | null> {
return this.calendarChannelMetadataService.findById(id, workspace.id);
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createCalendarChannel(
@Args('input') input: CreateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateCalendarChannel(
@Args('input') input: UpdateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteCalendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
CalendarChannelException,
CalendarChannelExceptionCode,
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof CalendarChannelException) {
switch (error.code) {
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND:
throw new NotFoundError(error);
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
import { ConnectedAccountResolver } from 'src/engine/metadata-modules/connected-account/resolvers/connected-account.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
ConnectedAccountMetadataService,
ConnectedAccountResolver,
ConnectedAccountGraphqlApiExceptionInterceptor,
],
exports: [ConnectedAccountMetadataService],
})
export class ConnectedAccountMetadataModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Injectable()
export class ConnectedAccountMetadataService {
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly repository: Repository<ConnectedAccountEntity>,
) {}
async findAll(workspaceId: string): Promise<ConnectedAccountDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findById(
id: string,
workspaceId: string,
): Promise<ConnectedAccountDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<ConnectedAccountEntity> & {
workspaceId: string;
handle: string;
provider: string;
userWorkspaceId: string;
},
): Promise<ConnectedAccountDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<ConnectedAccountEntity>,
): Promise<ConnectedAccountDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
);
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<ConnectedAccountDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -0,0 +1,37 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ConnectedAccountExceptionCode {
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_CONNECTED_ACCOUNT_INPUT = 'INVALID_CONNECTED_ACCOUNT_INPUT',
}
const getConnectedAccountExceptionUserFriendlyMessage = (
code: ConnectedAccountExceptionCode,
) => {
switch (code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
return msg`Connected account not found.`;
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
return msg`Invalid connected account input.`;
default:
assertUnreachable(code);
}
};
export class ConnectedAccountException extends CustomException<ConnectedAccountExceptionCode> {
constructor(
message: string,
code: ConnectedAccountExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectedAccountExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
FeatureFlagModule,
],
providers: [ConnectedAccountDataAccessService],
exports: [ConnectedAccountDataAccessService],
})
export class ConnectedAccountDataAccessModule {}
@@ -0,0 +1,346 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type FindOneOptions,
type FindOptionsWhere,
In,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ConnectedAccountDataAccessService {
private readonly logger = new Logger(ConnectedAccountDataAccessService.name);
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly coreRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
private async resolveUserWorkspaceId(
workspaceId: string,
workspaceMemberId: string,
): Promise<string | null> {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: workspaceMemberId },
});
if (!workspaceMember) {
return null;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId: workspaceMember.userId, workspaceId },
});
return userWorkspace?.id ?? null;
}
private async toCore(
workspaceId: string,
data: Partial<ConnectedAccountWorkspaceEntity>,
): Promise<Partial<ConnectedAccountEntity>> {
const {
handleAliases,
lastSyncHistoryId: _lastSyncHistoryId,
accountOwnerId,
...rest
} = data as Record<string, unknown>;
const coreData: Record<string, unknown> = { ...rest };
if (handleAliases !== undefined) {
coreData.handleAliases = isNonEmptyString(handleAliases)
? handleAliases.split(',').map((alias: string) => alias.trim())
: null;
}
if (accountOwnerId !== undefined) {
const userWorkspaceId = await this.resolveUserWorkspaceId(
workspaceId,
accountOwnerId as string,
);
if (!userWorkspaceId) {
this.logger.warn(
`Could not resolve userWorkspaceId for workspaceMember ${accountOwnerId}`,
);
}
coreData.userWorkspaceId = userWorkspaceId;
}
return coreData as Partial<ConnectedAccountEntity>;
}
private async toCoreWhere(
workspaceId: string,
where: Record<string, unknown>,
): Promise<FindOptionsWhere<ConnectedAccountEntity>> {
const { accountOwnerId, ...rest } = where;
const coreWhere: Record<string, unknown> = { ...rest, workspaceId };
if (accountOwnerId !== undefined) {
const userWorkspaceId = await this.resolveUserWorkspaceId(
workspaceId,
accountOwnerId as string,
);
if (userWorkspaceId) {
coreWhere.userWorkspaceId = userWorkspaceId;
} else {
this.logger.warn(
`toCoreWhere: could not resolve userWorkspaceId for workspaceMember ${accountOwnerId}, returning empty result`,
);
coreWhere.id = '00000000-0000-0000-0000-000000000000';
}
}
return coreWhere as FindOptionsWhere<ConnectedAccountEntity>;
}
private async fromCoreEntities(
workspaceId: string,
entities: ConnectedAccountEntity[],
): Promise<ConnectedAccountWorkspaceEntity[]> {
if (entities.length === 0) {
return [];
}
const userWorkspaceIds = entities
.map((entity) => entity.userWorkspaceId)
.filter(isDefined);
const userWorkspaces =
userWorkspaceIds.length > 0
? await this.userWorkspaceRepository.find({
where: { id: In(userWorkspaceIds) },
select: ['id', 'userId'],
})
: [];
const userIdByUserWorkspaceId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.id,
userWorkspace.userId,
]),
);
const uniqueUserIds = [
...new Set(userWorkspaces.map((userWorkspace) => userWorkspace.userId)),
];
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers =
uniqueUserIds.length > 0
? await workspaceMemberRepository.find({
where: { userId: In(uniqueUserIds) },
})
: [];
const workspaceMemberIdByUserId = new Map(
workspaceMembers.map((workspaceMember) => [
workspaceMember.userId,
workspaceMember.id,
]),
);
return entities.map((entity) => {
const userId = entity.userWorkspaceId
? userIdByUserWorkspaceId.get(entity.userWorkspaceId)
: undefined;
const accountOwnerId = userId
? workspaceMemberIdByUserId.get(userId)
: undefined;
const handleAliases = Array.isArray(entity.handleAliases)
? entity.handleAliases.join(',')
: (entity.handleAliases ?? '');
return {
...entity,
handleAliases,
accountOwnerId: accountOwnerId ?? null,
} as unknown as ConnectedAccountWorkspaceEntity;
});
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<ConnectedAccountWorkspaceEntity>,
): Promise<ConnectedAccountWorkspaceEntity | null> {
if (await this.isMigrated(workspaceId)) {
const where = options.where as Record<string, unknown>;
const coreWhere = Array.isArray(where)
? await Promise.all(
where.map((whereItem: Record<string, unknown>) =>
this.toCoreWhere(workspaceId, whereItem),
),
)
: await this.toCoreWhere(workspaceId, where);
const coreResult = await this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<ConnectedAccountEntity>);
if (!coreResult) {
return null;
}
const [transformed] = await this.fromCoreEntities(workspaceId, [
coreResult,
]);
return transformed ?? null;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
where?: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
): Promise<ConnectedAccountWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
const coreWhere = where
? await this.toCoreWhere(workspaceId, where as Record<string, unknown>)
: { workspaceId };
const coreResults = await this.coreRepository.find({
where: coreWhere,
});
return this.fromCoreEntities(workspaceId, coreResults);
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<ConnectedAccountWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data, {}, manager);
if (await this.isMigrated(workspaceId)) {
try {
const coreData = await this.toCore(workspaceId, data);
await this.coreRepository.save({
...coreData,
workspaceId,
} as ConnectedAccountEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
data: Partial<ConnectedAccountWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data);
if (await this.isMigrated(workspaceId)) {
try {
const coreData = await this.toCore(workspaceId, data);
const coreWhere = await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
await this.coreRepository.update(
coreWhere,
coreData as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount update to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.delete(where);
if (await this.isMigrated(workspaceId)) {
try {
const coreWhere = await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
await this.coreRepository.delete(coreWhere);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,90 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ConnectedAccountDTO')
export class ConnectedAccountDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsString()
@IsNotEmpty()
@Field()
provider: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
accessToken: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
refreshToken: string | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
lastCredentialsRefreshedAt: Date | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
authFailedAt: Date | null;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
handleAliases: string[] | null;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
scopes: string[] | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
connectionParameters: Record<string, unknown> | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
lastSignedInAt: Date | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
oidcTokenClaims: Record<string, unknown> | null;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
userWorkspaceId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateConnectedAccountInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsString()
@IsNotEmpty()
@Field()
provider: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
accessToken?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
refreshToken?: string;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
scopes?: string[];
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
userWorkspaceId: string;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateConnectedAccountInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
accessToken?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
refreshToken?: string;
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
handleAliases?: string[];
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
scopes?: string[];
}
@InputType()
export class UpdateConnectedAccountInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateConnectedAccountInputUpdates)
@ValidateNested()
@Field(() => UpdateConnectedAccountInputUpdates)
update: UpdateConnectedAccountInputUpdates;
}
@@ -0,0 +1,74 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'connectedAccount', schema: 'core' })
export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({ type: 'varchar', nullable: false })
provider: string;
@Column({ type: 'varchar', nullable: true })
accessToken: string | null;
@Column({ type: 'varchar', nullable: true })
refreshToken: string | null;
@Column({ type: 'timestamptz', nullable: true })
lastCredentialsRefreshedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
authFailedAt: Date | null;
@Column({ type: 'varchar', array: true, nullable: true })
handleAliases: string[] | null;
@Column({ type: 'varchar', array: true, nullable: true })
scopes: string[] | null;
@Column({ type: 'jsonb', nullable: true })
connectionParameters: Record<string, unknown> | null;
@Column({ type: 'timestamptz', nullable: true })
lastSignedInAt: Date | null;
@Column({ type: 'jsonb', nullable: true })
oidcTokenClaims: Record<string, unknown> | null;
@Column({ type: 'uuid', nullable: false })
userWorkspaceId: string;
@OneToMany(
'MessageChannelEntity',
(messageChannel: MessageChannelEntity) => messageChannel.connectedAccount,
)
messageChannels: Relation<MessageChannelEntity[]>;
@OneToMany(
'CalendarChannelEntity',
(calendarChannel: CalendarChannelEntity) =>
calendarChannel.connectedAccount,
)
calendarChannels: Relation<CalendarChannelEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { connectedAccountGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/connected-account/utils/connected-account-graphql-api-exception-handler.util';
@Injectable()
export class ConnectedAccountGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(connectedAccountGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,86 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { CreateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/create-connected-account.input';
import { UpdateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/update-connected-account.input';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(ConnectedAccountGraphqlApiExceptionInterceptor)
@MetadataResolver(() => ConnectedAccountDTO)
export class ConnectedAccountResolver {
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
@Query(() => [ConnectedAccountDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async connectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO[]> {
return this.connectedAccountMetadataService.findAll(workspace.id);
}
@Query(() => ConnectedAccountDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async connectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO | null> {
return this.connectedAccountMetadataService.findById(id, workspace.id);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createConnectedAccount(
@Args('input') input: CreateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateConnectedAccount(
@Args('input') input: UpdateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteConnectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
export const connectedAccountGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof ConnectedAccountException) {
switch (error.code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
throw new NotFoundError(error);
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
@Module({
imports: [
TypeOrmModule.forFeature([MessageChannelEntity]),
FeatureFlagModule,
ConnectedAccountDataAccessModule,
],
providers: [MessageChannelDataAccessService],
exports: [MessageChannelDataAccessService],
})
export class MessageChannelDataAccessModule {}
@@ -0,0 +1,280 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
type FindManyOptions,
type FindOneOptions,
type FindOptionsWhere,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@Injectable()
export class MessageChannelDataAccessService {
private readonly logger = new Logger(MessageChannelDataAccessService.name);
constructor(
@InjectRepository(MessageChannelEntity)
private readonly coreRepository: Repository<MessageChannelEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
private async toCoreWhere(
workspaceId: string,
where: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const coreWhere: Record<string, unknown> = { ...where, workspaceId };
if (
coreWhere.connectedAccount &&
typeof coreWhere.connectedAccount === 'object'
) {
const connectedAccountWhere = {
...(coreWhere.connectedAccount as Record<string, unknown>),
};
if ('accountOwnerId' in connectedAccountWhere) {
const { accountOwnerId, ...restConnectedAccount } =
connectedAccountWhere;
const resolvedConnectedAccounts =
await this.connectedAccountDataAccessService.find(workspaceId, {
accountOwnerId,
} as never);
if (resolvedConnectedAccounts.length > 0) {
coreWhere.connectedAccountId = resolvedConnectedAccounts[0].id;
} else {
coreWhere.connectedAccountId = '00000000-0000-0000-0000-000000000000';
}
if (Object.keys(restConnectedAccount).length > 0) {
coreWhere.connectedAccount = restConnectedAccount;
} else {
delete coreWhere.connectedAccount;
}
}
}
return coreWhere;
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<MessageChannelWorkspaceEntity>,
): Promise<MessageChannelWorkspaceEntity | null> {
if (await this.isMigrated(workspaceId)) {
const where = options.where as Record<string, unknown>;
const coreWhere = Array.isArray(where)
? await Promise.all(
where.map((whereItem: Record<string, unknown>) =>
this.toCoreWhere(workspaceId, whereItem),
),
)
: await this.toCoreWhere(workspaceId, where);
return this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<MessageChannelEntity>) as unknown as Promise<MessageChannelWorkspaceEntity | null>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
where?: FindOptionsWhere<MessageChannelWorkspaceEntity>,
): Promise<MessageChannelWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
return this.coreRepository.find({
where: {
...(where as Record<string, unknown>),
workspaceId,
} as FindOptionsWhere<MessageChannelEntity>,
}) as unknown as Promise<MessageChannelWorkspaceEntity[]>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async findMany(
workspaceId: string,
options: FindManyOptions<MessageChannelWorkspaceEntity>,
): Promise<MessageChannelWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
const baseWhere = options.where;
if (!baseWhere) {
return this.coreRepository.find({
...options,
where: { workspaceId },
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
MessageChannelWorkspaceEntity[]
>;
}
if (Array.isArray(baseWhere)) {
const coreWhereArray = await Promise.all(
baseWhere.map((whereItem) =>
this.toCoreWhere(workspaceId, whereItem as Record<string, unknown>),
),
);
return this.coreRepository.find({
...options,
where: coreWhereArray,
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
MessageChannelWorkspaceEntity[]
>;
}
const coreWhere = await this.toCoreWhere(
workspaceId,
baseWhere as Record<string, unknown>,
);
return this.coreRepository.find({
...options,
where: coreWhere,
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
MessageChannelWorkspaceEntity[]
>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find(options);
}
async save(
workspaceId: string,
data: Partial<MessageChannelWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data, {}, manager);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.save({
...data,
workspaceId,
} as unknown as MessageChannelEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write messageChannel to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
data: Partial<MessageChannelWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data, manager);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.update(
{ ...where, workspaceId } as FindOptionsWhere<MessageChannelEntity>,
data as never,
);
} catch (error) {
this.logger.error(
`Failed to dual-write messageChannel update to core: ${error}`,
);
throw error;
}
}
}
async increment(
workspaceId: string,
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
propertyPath: string,
value: number,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.increment(where, propertyPath, value, undefined, [
propertyPath,
'id',
]);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.increment(
{
...where,
workspaceId,
} as FindOptionsWhere<MessageChannelEntity>,
propertyPath,
value,
);
} catch (error) {
this.logger.error(
`Failed to dual-write messageChannel increment to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.delete(where);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.delete({
...where,
workspaceId,
} as FindOptionsWhere<MessageChannelEntity>);
} catch (error) {
this.logger.error(
`Failed to dual-write messageChannel delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,88 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
MessageChannelContactAutoCreationPolicy,
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
MessageChannelType,
MessageChannelVisibility,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateMessageChannelInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(MessageChannelVisibility)
@IsNotEmpty()
@Field(() => MessageChannelVisibility)
visibility: MessageChannelVisibility;
@IsEnum(MessageChannelType)
@IsNotEmpty()
@Field(() => MessageChannelType)
type: MessageChannelType;
@IsEnum(MessageChannelSyncStage)
@IsNotEmpty()
@Field(() => MessageChannelSyncStage)
syncStage: MessageChannelSyncStage;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(MessageChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => MessageChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
@IsEnum(MessageFolderImportPolicy)
@IsNotEmpty()
@Field(() => MessageFolderImportPolicy)
messageFolderImportPolicy: MessageFolderImportPolicy;
@IsBoolean()
@IsNotEmpty()
@Field()
excludeNonProfessionalEmails: boolean;
@IsBoolean()
@IsNotEmpty()
@Field()
excludeGroupEmails: boolean;
@IsEnum(MessageChannelPendingGroupEmailsAction)
@IsNotEmpty()
@Field(() => MessageChannelPendingGroupEmailsAction)
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
@IsBoolean()
@IsNotEmpty()
@Field()
isSyncEnabled: boolean;
}
@@ -0,0 +1,127 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
MessageChannelContactAutoCreationPolicy,
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
MessageChannelVisibility,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('MessageChannel')
export class MessageChannelDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsEnum(MessageChannelVisibility)
@IsNotEmpty()
@Field(() => MessageChannelVisibility)
visibility: MessageChannelVisibility;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(MessageChannelType)
@IsNotEmpty()
@Field(() => MessageChannelType)
type: MessageChannelType;
@IsBoolean()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(MessageChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => MessageChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
@IsEnum(MessageFolderImportPolicy)
@IsNotEmpty()
@Field(() => MessageFolderImportPolicy)
messageFolderImportPolicy: MessageFolderImportPolicy;
@IsBoolean()
@Field()
excludeNonProfessionalEmails: boolean;
@IsBoolean()
@Field()
excludeGroupEmails: boolean;
@IsEnum(MessageChannelPendingGroupEmailsAction)
@IsNotEmpty()
@Field(() => MessageChannelPendingGroupEmailsAction)
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
@IsBoolean()
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
syncCursor: string | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncedAt: Date | null;
@IsEnum(MessageChannelSyncStatus)
@IsNotEmpty()
@Field(() => MessageChannelSyncStatus)
syncStatus: MessageChannelSyncStatus;
@IsEnum(MessageChannelSyncStage)
@IsNotEmpty()
@Field(() => MessageChannelSyncStage)
syncStage: MessageChannelSyncStage;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncStageStartedAt: Date | null;
@IsInt()
@Field()
throttleFailureCount: number;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
throttleRetryAfter: Date | null;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,69 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import {
MessageChannelContactAutoCreationPolicy,
MessageChannelVisibility,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateMessageChannelInputUpdates {
@IsOptional()
@IsEnum(MessageChannelVisibility)
@Field(() => MessageChannelVisibility, { nullable: true })
visibility?: MessageChannelVisibility;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isContactAutoCreationEnabled?: boolean;
@IsOptional()
@IsEnum(MessageChannelContactAutoCreationPolicy)
@Field(() => MessageChannelContactAutoCreationPolicy, { nullable: true })
contactAutoCreationPolicy?: MessageChannelContactAutoCreationPolicy;
@IsOptional()
@IsEnum(MessageFolderImportPolicy)
@Field(() => MessageFolderImportPolicy, { nullable: true })
messageFolderImportPolicy?: MessageFolderImportPolicy;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSyncEnabled?: boolean;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
excludeNonProfessionalEmails?: boolean;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
excludeGroupEmails?: boolean;
}
@InputType()
export class UpdateMessageChannelInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateMessageChannelInputUpdates)
@ValidateNested()
@Field(() => UpdateMessageChannelInputUpdates)
update: UpdateMessageChannelInputUpdates;
}
@@ -0,0 +1,134 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import {
MessageChannelContactAutoCreationPolicy,
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
MessageChannelVisibility,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'messageChannel', schema: 'core' })
export class MessageChannelEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({
type: 'enum',
enum: MessageChannelVisibility,
nullable: false,
})
visibility: MessageChannelVisibility;
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({
type: 'enum',
enum: MessageChannelType,
nullable: false,
})
type: MessageChannelType;
@Column({ type: 'boolean', nullable: false })
isContactAutoCreationEnabled: boolean;
@Column({
type: 'enum',
enum: MessageChannelContactAutoCreationPolicy,
nullable: false,
})
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
@Column({
type: 'enum',
enum: MessageFolderImportPolicy,
nullable: false,
})
messageFolderImportPolicy: MessageFolderImportPolicy;
@Column({ type: 'boolean', nullable: false })
excludeNonProfessionalEmails: boolean;
@Column({ type: 'boolean', nullable: false })
excludeGroupEmails: boolean;
@Column({
type: 'enum',
enum: MessageChannelPendingGroupEmailsAction,
nullable: false,
})
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
@Column({ type: 'boolean', nullable: false })
isSyncEnabled: boolean;
@Column({ type: 'varchar', nullable: true })
syncCursor: string | null;
@Column({ type: 'timestamptz', nullable: true })
syncedAt: Date | null;
@Column({
type: 'enum',
enum: MessageChannelSyncStatus,
nullable: false,
default: MessageChannelSyncStatus.NOT_SYNCED,
})
syncStatus: MessageChannelSyncStatus;
@Column({
type: 'enum',
enum: MessageChannelSyncStage,
nullable: false,
})
syncStage: MessageChannelSyncStage;
@Column({ type: 'timestamptz', nullable: true })
syncStageStartedAt: Date | null;
@Column({ type: 'integer', nullable: false, default: 0 })
throttleFailureCount: number;
@Column({ type: 'timestamptz', nullable: true })
throttleRetryAfter: Date | null;
@Column({ type: 'uuid', nullable: false })
connectedAccountId: string;
@ManyToOne(
() => ConnectedAccountEntity,
(connectedAccount) => connectedAccount.messageChannels,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'connectedAccountId' })
connectedAccount: Relation<ConnectedAccountEntity>;
@OneToMany(
'MessageFolderEntity',
(messageFolder: MessageFolderEntity) => messageFolder.messageChannel,
)
messageFolders: Relation<MessageFolderEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { messageChannelGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util';
@Injectable()
export class MessageChannelGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(messageChannelGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
import { MessageChannelResolver } from 'src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([MessageChannelEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
MessageChannelMetadataService,
MessageChannelResolver,
MessageChannelGraphqlApiExceptionInterceptor,
],
exports: [MessageChannelMetadataService],
})
export class MessageChannelMetadataModule {}
@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
MessageChannelSyncStage,
MessageChannelType,
MessageChannelVisibility,
} from 'twenty-shared/types';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
@Injectable()
export class MessageChannelMetadataService {
constructor(
@InjectRepository(MessageChannelEntity)
private readonly repository: Repository<MessageChannelEntity>,
) {}
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByConnectedAccountId(
connectedAccountId: string,
workspaceId: string,
): Promise<MessageChannelDTO[]> {
return this.repository.find({
where: { connectedAccountId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<MessageChannelDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<MessageChannelEntity> & {
workspaceId: string;
handle: string;
connectedAccountId: string;
visibility: MessageChannelVisibility;
type: MessageChannelType;
syncStage: MessageChannelSyncStage;
},
): Promise<MessageChannelDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<MessageChannelEntity>,
): Promise<MessageChannelDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
);
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<MessageChannelDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -0,0 +1,37 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum MessageChannelExceptionCode {
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT',
}
const getMessageChannelExceptionUserFriendlyMessage = (
code: MessageChannelExceptionCode,
) => {
switch (code) {
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
return msg`Message channel not found.`;
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
return msg`Invalid message channel input.`;
default:
assertUnreachable(code);
}
};
export class MessageChannelException extends CustomException<MessageChannelExceptionCode> {
constructor(
message: string,
code: MessageChannelExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getMessageChannelExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,98 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-message-channel.input';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(MessageChannelGraphqlApiExceptionInterceptor)
@MetadataResolver(() => MessageChannelDTO)
export class MessageChannelResolver {
constructor(
private readonly messageChannelMetadataService: MessageChannelMetadataService,
) {}
@Query(() => [MessageChannelDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageChannels(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('connectedAccountId', {
type: () => UUIDScalarType,
nullable: true,
})
connectedAccountId?: string,
): Promise<MessageChannelDTO[]> {
if (connectedAccountId) {
return this.messageChannelMetadataService.findByConnectedAccountId(
connectedAccountId,
workspace.id,
);
}
return this.messageChannelMetadataService.findAll(workspace.id);
}
@Query(() => MessageChannelDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO | null> {
return this.messageChannelMetadataService.findById(id, workspace.id);
}
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createMessageChannel(
@Args('input') input: CreateMessageChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateMessageChannel(
@Args('input') input: UpdateMessageChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => MessageChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteMessageChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof MessageChannelException) {
switch (error.code) {
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
throw new NotFoundError(error);
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageFolderDataAccessService } from 'src/engine/metadata-modules/message-folder/data-access/services/message-folder-data-access.service';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
@Module({
imports: [TypeOrmModule.forFeature([MessageFolderEntity]), FeatureFlagModule],
providers: [MessageFolderDataAccessService],
exports: [MessageFolderDataAccessService],
})
export class MessageFolderDataAccessModule {}
@@ -0,0 +1,157 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
type FindOneOptions,
type FindOptionsWhere,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
@Injectable()
export class MessageFolderDataAccessService {
private readonly logger = new Logger(MessageFolderDataAccessService.name);
constructor(
@InjectRepository(MessageFolderEntity)
private readonly coreRepository: Repository<MessageFolderEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<MessageFolderWorkspaceEntity>,
): Promise<MessageFolderWorkspaceEntity | null> {
if (await this.isMigrated(workspaceId)) {
const where = options.where as Record<string, unknown>;
const coreWhere = Array.isArray(where)
? where.map((whereItem) => ({
...(whereItem as Record<string, unknown>),
workspaceId,
}))
: {
...(where as Record<string, unknown>),
workspaceId,
};
return this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<MessageFolderEntity>) as unknown as Promise<MessageFolderWorkspaceEntity | null>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
where?: FindOptionsWhere<MessageFolderWorkspaceEntity>,
): Promise<MessageFolderWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
return this.coreRepository.find({
where: {
...(where as Record<string, unknown>),
workspaceId,
} as FindOptionsWhere<MessageFolderEntity>,
}) as unknown as Promise<MessageFolderWorkspaceEntity[]>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<MessageFolderWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.save({
...data,
workspaceId,
} as unknown as MessageFolderEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<MessageFolderWorkspaceEntity>,
data: Partial<MessageFolderWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data, manager);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.update(
{ ...where, workspaceId } as FindOptionsWhere<MessageFolderEntity>,
data as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder update to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<MessageFolderWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.delete(where);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.delete({
...where,
workspaceId,
} as FindOptionsWhere<MessageFolderEntity>);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,56 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateMessageFolderInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
name?: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isSentFolder: boolean;
@IsBoolean()
@IsNotEmpty()
@Field()
isSynced: boolean;
@IsString()
@IsOptional()
@Field({ nullable: true })
externalId?: string;
@IsEnum(MessageFolderPendingSyncAction)
@IsNotEmpty()
@Field(() => MessageFolderPendingSyncAction)
pendingSyncAction: MessageFolderPendingSyncAction;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
messageChannelId: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
parentFolderId?: string;
}
@@ -0,0 +1,71 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('MessageFolder')
export class MessageFolderDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
name: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
syncCursor: string | null;
@IsBoolean()
@Field()
isSentFolder: boolean;
@IsBoolean()
@Field()
isSynced: boolean;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
parentFolderId: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
externalId: string | null;
@IsEnum(MessageFolderPendingSyncAction)
@IsNotEmpty()
@Field(() => MessageFolderPendingSyncAction)
pendingSyncAction: MessageFolderPendingSyncAction;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
messageChannelId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,51 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateMessageFolderInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
name?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
syncCursor?: string;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSynced?: boolean;
@IsOptional()
@IsEnum(MessageFolderPendingSyncAction)
@Field(() => MessageFolderPendingSyncAction, { nullable: true })
pendingSyncAction?: MessageFolderPendingSyncAction;
}
@InputType()
export class UpdateMessageFolderInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateMessageFolderInputUpdates)
@ValidateNested()
@Field(() => UpdateMessageFolderInputUpdates)
update: UpdateMessageFolderInputUpdates;
}
@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'messageFolder', schema: 'core' })
export class MessageFolderEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: true })
name: string | null;
@Column({ type: 'varchar', nullable: true })
syncCursor: string | null;
@Column({ type: 'boolean', nullable: false })
isSentFolder: boolean;
@Column({ type: 'boolean', nullable: false })
isSynced: boolean;
@Column({ type: 'uuid', nullable: true })
parentFolderId: string | null;
@Column({ type: 'varchar', nullable: true })
externalId: string | null;
@Column({
type: 'enum',
enum: MessageFolderPendingSyncAction,
nullable: false,
})
pendingSyncAction: MessageFolderPendingSyncAction;
@Column({ type: 'uuid', nullable: false })
messageChannelId: string;
@ManyToOne(
() => MessageChannelEntity,
(messageChannel) => messageChannel.messageFolders,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'messageChannelId' })
messageChannel: Relation<MessageChannelEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { messageFolderGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/message-folder/utils/message-folder-graphql-api-exception-handler.util';
@Injectable()
export class MessageFolderGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(messageFolderGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
import { MessageFolderResolver } from 'src/engine/metadata-modules/message-folder/resolvers/message-folder.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([MessageFolderEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
MessageFolderMetadataService,
MessageFolderResolver,
MessageFolderGraphqlApiExceptionInterceptor,
],
exports: [MessageFolderMetadataService],
})
export class MessageFolderMetadataModule {}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
@Injectable()
export class MessageFolderMetadataService {
constructor(
@InjectRepository(MessageFolderEntity)
private readonly repository: Repository<MessageFolderEntity>,
) {}
async findAll(workspaceId: string): Promise<MessageFolderDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByMessageChannelId(
messageChannelId: string,
workspaceId: string,
): Promise<MessageFolderDTO[]> {
return this.repository.find({
where: { messageChannelId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<MessageFolderDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<MessageFolderEntity> & {
workspaceId: string;
messageChannelId: string;
pendingSyncAction: MessageFolderPendingSyncAction;
},
): Promise<MessageFolderDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<MessageFolderEntity>,
): Promise<MessageFolderDTO> {
await this.repository.update(
{ id, workspaceId },
data as Record<string, unknown>,
);
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async delete(id: string, workspaceId: string): Promise<MessageFolderDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -0,0 +1,37 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum MessageFolderExceptionCode {
MESSAGE_FOLDER_NOT_FOUND = 'MESSAGE_FOLDER_NOT_FOUND',
INVALID_MESSAGE_FOLDER_INPUT = 'INVALID_MESSAGE_FOLDER_INPUT',
}
const getMessageFolderExceptionUserFriendlyMessage = (
code: MessageFolderExceptionCode,
) => {
switch (code) {
case MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND:
return msg`Message folder not found.`;
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
return msg`Invalid message folder input.`;
default:
assertUnreachable(code);
}
};
export class MessageFolderException extends CustomException<MessageFolderExceptionCode> {
constructor(
message: string,
code: MessageFolderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getMessageFolderExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,98 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/create-message-folder.input';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { UpdateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(MessageFolderGraphqlApiExceptionInterceptor)
@MetadataResolver(() => MessageFolderDTO)
export class MessageFolderResolver {
constructor(
private readonly messageFolderMetadataService: MessageFolderMetadataService,
) {}
@Query(() => [MessageFolderDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolders(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('messageChannelId', {
type: () => UUIDScalarType,
nullable: true,
})
messageChannelId?: string,
): Promise<MessageFolderDTO[]> {
if (messageChannelId) {
return this.messageFolderMetadataService.findByMessageChannelId(
messageChannelId,
workspace.id,
);
}
return this.messageFolderMetadataService.findAll(workspace.id);
}
@Query(() => MessageFolderDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO | null> {
return this.messageFolderMetadataService.findById(id, workspace.id);
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createMessageFolder(
@Args('input') input: CreateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateMessageFolder(
@Args('input') input: UpdateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteMessageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
MessageFolderException,
MessageFolderExceptionCode,
} from 'src/engine/metadata-modules/message-folder/message-folder.exception';
export const messageFolderGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof MessageFolderException) {
switch (error.code) {
case MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND:
throw new NotFoundError(error);
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -5,6 +5,8 @@ import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-mo
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module';
import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module';
import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
@@ -12,6 +14,8 @@ import { FlatEntityMapsGraphqlApiExceptionFilter } from 'src/engine/metadata-mod
import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module';
import { LogicFunctionLayerModule } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
import { MessageFolderMetadataModule } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -47,6 +51,10 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
PermissionsModule,
RouteTriggerModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
CalendarChannelMetadataModule,
MessageFolderMetadataModule,
],
providers: [
{
@@ -71,6 +79,10 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
RoleModule,
PermissionsModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
CalendarChannelMetadataModule,
MessageFolderMetadataModule,
],
})
export class MetadataEngineModule {}
@@ -250,6 +250,7 @@ describe('WorkspaceEntityManager', () => {
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false,
IS_DRAFT_EMAIL_ENABLED: false,
IS_RICH_TEXT_V1_MIGRATED: false,
IS_CONNECTED_ACCOUNT_MIGRATED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { BlocklistItemDeleteCalendarEventsJob } from 'src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job';
import { BlocklistReimportCalendarEventsJob } from 'src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job';
import { CalendarBlocklistListener } from 'src/modules/calendar/blocklist-manager/listeners/calendar-blocklist.listener';
@@ -7,7 +8,11 @@ import { CalendarEventCleanerModule } from 'src/modules/calendar/calendar-event-
import { CalendarCommonModule } from 'src/modules/calendar/common/calendar-common.module';
@Module({
imports: [CalendarEventCleanerModule, CalendarCommonModule],
imports: [
CalendarEventCleanerModule,
CalendarCommonModule,
CalendarChannelDataAccessModule,
],
providers: [
CalendarBlocklistListener,
BlocklistItemDeleteCalendarEventsJob,
@@ -7,13 +7,13 @@ import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarEventCleanerService } from 'src/modules/calendar/calendar-event-cleaner/services/calendar-event-cleaner.service';
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type BlocklistItemDeleteCalendarEventsJobData = WorkspaceEventBatch<
ObjectRecordCreateEvent<BlocklistWorkspaceEntity>
@@ -26,6 +26,7 @@ export type BlocklistItemDeleteCalendarEventsJobData = WorkspaceEventBatch<
export class BlocklistItemDeleteCalendarEventsJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarEventCleanerService: CalendarEventCleanerService,
) {}
@@ -71,12 +72,6 @@ export class BlocklistItemDeleteCalendarEventsJob {
new Map<string, string[]>(),
);
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannelEventAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
workspaceId,
@@ -91,29 +86,35 @@ export class BlocklistItemDeleteCalendarEventsJob {
continue;
}
const calendarChannels = await calendarChannelRepository.find({
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
},
},
},
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
},
},
},
relations: ['connectedAccount'],
});
relations: ['connectedAccount'],
});
for (const calendarChannel of calendarChannels) {
const calendarChannelHandles = [calendarChannel.handle];
if (calendarChannel.connectedAccount.handleAliases) {
calendarChannelHandles.push(
...calendarChannel.connectedAccount.handleAliases.split(','),
);
const rawAliases = calendarChannel.connectedAccount
.handleAliases as string | string[];
const aliasList = Array.isArray(rawAliases)
? rawAliases
: rawAliases.split(',').map((alias: string) => alias.trim());
calendarChannelHandles.push(...aliasList);
}
const handleConditions = handles.map((handle) => {
@@ -6,15 +6,13 @@ import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type BlocklistReimportCalendarEventsJobData = WorkspaceEventBatch<
ObjectRecordDeleteEvent<BlocklistWorkspaceEntity>
@@ -27,6 +25,7 @@ export type BlocklistReimportCalendarEventsJobData = WorkspaceEventBatch<
export class BlocklistReimportCalendarEventsJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
) {}
@@ -37,27 +36,22 @@ export class BlocklistReimportCalendarEventsJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
for (const eventPayload of data.events) {
const workspaceMemberId =
eventPayload.properties.before.workspaceMemberId;
const calendarChannels = await calendarChannelRepository.find({
select: ['id'],
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
select: ['id'],
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
},
syncStage: Not(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
),
},
syncStage: Not(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
),
},
});
});
await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending(
calendarChannels.map((calendarChannel) => calendarChannel.id),
@@ -6,6 +6,8 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
@@ -57,6 +59,8 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
ConnectedAccountModule,
CalendarCommonModule,
MetricsModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
],
providers: [
CalendarAccountAuthenticationService,
@@ -5,16 +5,14 @@ import { Command, CommandRunner, Option } from 'nest-commander';
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';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarEventListFetchJob,
type CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
type CalendarTriggerEventListFetchCommandOptions = {
workspaceId: string;
@@ -33,6 +31,7 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
) {
@@ -52,23 +51,17 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const whereCondition: Record<string, unknown> = {
isSyncEnabled: true,
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
};
if (calendarChannelId) {
whereCondition.id = calendarChannelId;
}
const calendarChannels =
await calendarChannelRepository.find(whereCondition);
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
isSyncEnabled: true,
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(calendarChannelId ? { id: calendarChannelId } : {}),
},
},
);
if (calendarChannels.length === 0) {
this.logger.warn(
@@ -83,11 +76,15 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
);
for (const calendarChannel of calendarChannels) {
await calendarChannelRepository.update(calendarChannel.id, {
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannel.id },
{
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStageStartedAt: new Date().toISOString(),
},
);
await this.messageQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
@@ -3,6 +3,7 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
@@ -11,6 +12,7 @@ import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
export type CalendarEventListFetchJobData = {
@@ -25,6 +27,7 @@ export type CalendarEventListFetchJobData = {
export class CalendarEventListFetchJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly calendarFetchEventsService: CalendarFetchEventsService,
) {}
@@ -36,19 +39,14 @@ export class CalendarEventListFetchJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
if (!calendarChannel) {
return;
@@ -61,11 +59,10 @@ export class CalendarEventListFetchJob {
return;
}
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (
isThrottled(
calendarChannel.syncStageStartedAt,
calendarChannel.throttleFailureCount,
)
isThrottled(syncStageStartedAt, calendarChannel.throttleFailureCount)
) {
await this.calendarChannelSyncStatusService.markAsCalendarEventListFetchPending(
[calendarChannel.id],
@@ -77,8 +74,8 @@ export class CalendarEventListFetchJob {
}
await this.calendarFetchEventsService.fetchCalendarEvents(
calendarChannel,
calendarChannel.connectedAccount,
calendarChannel as unknown as CalendarChannelWorkspaceEntity,
calendarChannel.connectedAccount as unknown as ConnectedAccountWorkspaceEntity,
workspaceId,
);
}, authContext);
@@ -3,6 +3,7 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
@@ -11,6 +12,7 @@ import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
export type CalendarEventsImportJobData = {
@@ -27,6 +29,7 @@ export class CalendarEventsImportJob {
private readonly calendarEventsImportService: CalendarEventsImportService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
@Process(CalendarEventsImportJob.name)
@@ -36,18 +39,14 @@ export class CalendarEventsImportJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
if (!calendarChannel?.isSyncEnabled) {
return;
@@ -60,11 +59,10 @@ export class CalendarEventsImportJob {
return;
}
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (
isThrottled(
calendarChannel.syncStageStartedAt,
calendarChannel.throttleFailureCount,
)
isThrottled(syncStageStartedAt, calendarChannel.throttleFailureCount)
) {
await this.calendarChannelSyncStatusService.markAsCalendarEventsImportPending(
[calendarChannel.id],
@@ -76,8 +74,8 @@ export class CalendarEventsImportJob {
}
await this.calendarEventsImportService.processCalendarEventsImport(
calendarChannel,
calendarChannel.connectedAccount,
calendarChannel as unknown as CalendarChannelWorkspaceEntity,
calendarChannel.connectedAccount as unknown as ConnectedAccountWorkspaceEntity,
workspaceId,
);
}, authContext);
@@ -5,14 +5,12 @@ import { In } from 'typeorm';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { isSyncStale } from 'src/modules/calendar/calendar-event-import-manager/utils/is-sync-stale.util';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CalendarOngoingStaleJobData = {
workspaceId: string;
@@ -26,6 +24,7 @@ export class CalendarOngoingStaleJob {
private readonly logger = new Logger(CalendarOngoingStaleJob.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
) {}
@@ -36,25 +35,24 @@ export class CalendarOngoingStaleJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
syncStage: In([
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_SCHEDULED,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
]),
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
syncStage: In([
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_SCHEDULED,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
]),
},
},
});
);
for (const calendarChannel of calendarChannels) {
if (isSyncStale(calendarChannel.syncStageStartedAt)) {
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (isSyncStale(syncStageStartedAt)) {
await this.calendarChannelSyncStatusService.resetSyncStageStartedAt(
[calendarChannel.id],
workspaceId,
@@ -3,12 +3,12 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CalendarRelaunchFailedCalendarChannelJobData = {
@@ -23,6 +23,7 @@ export type CalendarRelaunchFailedCalendarChannelJobData = {
export class CalendarRelaunchFailedCalendarChannelJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
@Process(CalendarRelaunchFailedCalendarChannelJob.name)
@@ -32,23 +33,12 @@ export class CalendarRelaunchFailedCalendarChannelJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
{ shouldBypassPermissionChecks: true },
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
},
relations: {
connectedAccount: {
accountOwner: true,
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
},
},
});
});
if (
!calendarChannel ||
@@ -58,10 +48,14 @@ export class CalendarRelaunchFailedCalendarChannelJob {
return;
}
await calendarChannelRepository.update(calendarChannelId, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
},
);
}, authContext);
}
}
@@ -1,12 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import {
type TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CALENDAR_THROTTLE_MAX_ATTEMPTS } from 'src/modules/calendar/calendar-event-import-manager/constants/calendar-throttle-max-attempts';
import {
type CalendarEventImportDriverException,
@@ -29,7 +28,7 @@ export class CalendarEventImportErrorHandlerService {
CalendarEventImportErrorHandlerService.name,
);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@@ -133,25 +132,14 @@ export class CalendarEventImportErrorHandlerService {
throw calendarEventImportException;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.increment(
{
id: calendarChannel.id,
},
'throttleFailureCount',
1,
undefined,
['throttleFailureCount', 'id'],
);
}, authContext);
await this.calendarChannelDataAccessService.increment(
workspaceId,
{
id: calendarChannel.id,
},
'throttleFailureCount',
1,
);
switch (syncStep) {
case CalendarEventImportSyncStep.CALENDAR_EVENT_LIST_FETCH:
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -29,6 +30,7 @@ export class CalendarFetchEventsService {
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
private readonly cacheStorage: CacheStorageService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly getCalendarEventsService: CalendarGetCalendarEventsService,
private readonly calendarEventImportErrorHandlerService: CalendarEventImportErrorHandlerService,
@@ -90,14 +92,9 @@ export class CalendarFetchEventsService {
const calendarEventIds = getCalendarEventsResponse.calendarEventIds;
const nextSyncCursor = getCalendarEventsResponse.nextSyncCursor;
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
if (!calendarEvents || calendarEvents?.length === 0) {
await calendarChannelRepository.update(
await this.calendarChannelDataAccessService.update(
workspaceId,
{
id: calendarChannel.id,
},
@@ -112,7 +109,8 @@ export class CalendarFetchEventsService {
);
}
await calendarChannelRepository.update(
await this.calendarChannelDataAccessService.update(
workspaceId,
{
id: calendarChannel.id,
},
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@@ -12,6 +14,8 @@ import { ConnectedAccountModule } from 'src/modules/connected-account/connected-
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity]),
ConnectedAccountModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MetricsModule,
],
providers: [CalendarChannelSyncStatusService],
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
@@ -44,22 +45,19 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
find: jest.fn(),
};
const mockConnectedAccountRepository = {
find: jest.fn(),
};
const mockWorkspaceMemberRepository = {
findOneByOrFail: jest.fn(),
};
const mockConnectedAccountDataAccessService = {
find: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
getRepository: jest.fn().mockImplementation((workspaceId, name) => {
if (name === 'calendarChannelEventAssociation') {
return mockCalendarEventAssociationRepository;
}
if (name === 'connectedAccount') {
return mockConnectedAccountRepository;
}
if (name === 'workspaceMember') {
return mockWorkspaceMemberRepository;
}
@@ -77,6 +75,10 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
provide: GlobalWorkspaceOrmManager,
useValue: mockGlobalWorkspaceOrmManager,
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
],
}).compile();
@@ -121,7 +123,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
item.description !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
),
).toBe(true);
expect(mockConnectedAccountRepository.find).not.toHaveBeenCalled();
expect(mockConnectedAccountDataAccessService.find).not.toHaveBeenCalled();
});
it('should return calendar event with obfuscated title and description if the visibility is METADATA', async () => {
@@ -143,7 +145,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -179,7 +181,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-account-owner-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([{ id: '1' }]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([{ id: '1' }]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -215,7 +217,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-not-account-owner-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -261,7 +263,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
},
]);
mockConnectedAccountRepository.find
mockConnectedAccountDataAccessService.find
.mockResolvedValueOnce([]) // request for calendar event 3
.mockResolvedValueOnce([{ id: '1' }]); // request for calendar event 2
@@ -317,7 +319,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
},
]);
mockConnectedAccountRepository.find
mockConnectedAccountDataAccessService.find
.mockResolvedValueOnce([]) // request for calendar event 3
.mockResolvedValueOnce([{ id: '1' }]); // request for calendar event 2
@@ -5,18 +5,19 @@ import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ApplyCalendarEventsVisibilityRestrictionsService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
public async applyCalendarEventsVisibilityRestrictions(
@@ -42,12 +43,6 @@ export class ApplyCalendarEventsVisibilityRestrictionsService {
relations: ['calendarChannel'],
});
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
@@ -84,15 +79,13 @@ export class ApplyCalendarEventsVisibilityRestrictionsService {
userId: userId,
});
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
const connectedAccounts =
await this.connectedAccountDataAccessService.find(workspaceId, {
calendarChannels: {
id: In(calendarChannels.map((channel) => channel.id)),
},
accountOwnerId: workspaceMember.id,
},
});
});
if (connectedAccounts.length > 0) {
continue;
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { CalendarEventFindManyPostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook';
import { CalendarEventFindOnePostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook';
import { ApplyCalendarEventsVisibilityRestrictionsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service';
@Module({
imports: [ConnectedAccountDataAccessModule],
providers: [
ApplyCalendarEventsVisibilityRestrictionsService,
CalendarEventFindOnePostQueryHook,
@@ -1,22 +1,23 @@
import { Injectable } from '@nestjs/common';
import { Any } from 'typeorm';
import { Any, In } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class CalendarChannelSyncStatusService {
@@ -24,6 +25,8 @@ export class CalendarChannelSyncStatusService {
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
private readonly cacheStorage: CacheStorageService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly accountsToReconnectService: AccountsToReconnectService,
private readonly metricsService: MetricsService,
) {}
@@ -40,16 +43,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
},
);
}, authContext);
}
@@ -64,17 +65,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
},
);
}, authContext);
}
@@ -95,17 +94,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncCursor: '',
syncStageStartedAt: null,
throttleFailureCount: 0,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncCursor: '',
syncStageStartedAt: null,
throttleFailureCount: 0,
},
);
}, authContext);
await this.markAsCalendarEventListFetchPending(
@@ -125,15 +122,13 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStageStartedAt: null,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStageStartedAt: null,
},
);
}, authContext);
}
@@ -149,16 +144,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
},
);
}, authContext);
}
@@ -173,17 +166,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
},
);
}, authContext);
}
@@ -198,19 +189,17 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
throttleFailureCount: 0,
syncStageStartedAt: null,
syncedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
throttleFailureCount: 0,
syncStageStartedAt: null,
syncedAt: new Date().toISOString(),
},
);
}, authContext);
await this.markAsCalendarEventListFetchPending(
@@ -241,16 +230,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
syncStage: CalendarChannelSyncStage.FAILED,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
syncStage: CalendarChannelSyncStage.FAILED,
},
);
}, authContext);
await this.metricsService.batchIncrementCounter({
@@ -276,33 +263,29 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
syncStage: CalendarChannelSyncStage.FAILED,
},
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
syncStage: CalendarChannelSyncStage.FAILED,
});
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const calendarChannels = await calendarChannelRepository.find({
select: ['id', 'connectedAccountId'],
where: { id: Any(calendarChannelIds) },
});
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
select: ['id', 'connectedAccountId'],
where: { id: Any(calendarChannelIds) },
},
);
const connectedAccountIds = calendarChannels.map(
(calendarChannel) => calendarChannel.connectedAccountId,
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{ id: Any(connectedAccountIds) },
{
authFailedAt: new Date(),
@@ -329,26 +312,43 @@ export class CalendarChannelSyncStatusService {
return;
}
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
id: Any(calendarChannelIds),
},
relations: {
connectedAccount: {
accountOwner: true,
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
select: ['id', 'connectedAccountId'],
where: {
id: Any(calendarChannelIds),
},
},
});
);
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (const calendarChannel of calendarChannels) {
const userId = calendarChannel.connectedAccount.accountOwner.userId;
const connectedAccountId = calendarChannel.connectedAccount.id;
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: calendarChannel.connectedAccountId },
});
if (!connectedAccount) {
continue;
}
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: connectedAccount.accountOwnerId },
});
if (!workspaceMember) {
continue;
}
const userId = workspaceMember.userId;
const connectedAccountId = connectedAccount.id;
await this.accountsToReconnectService.addAccountToReconnectByKey(
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
@@ -1,6 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
FieldMetadataType,
} from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
@@ -8,36 +14,12 @@ import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migr
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export enum CalendarChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
}
export enum CalendarChannelSyncStatus {
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING',
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
}
export enum CalendarChannelSyncStage {
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION',
CALENDAR_EVENT_LIST_FETCH_PENDING = 'CALENDAR_EVENT_LIST_FETCH_PENDING',
CALENDAR_EVENT_LIST_FETCH_SCHEDULED = 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED',
CALENDAR_EVENT_LIST_FETCH_ONGOING = 'CALENDAR_EVENT_LIST_FETCH_ONGOING',
CALENDAR_EVENTS_IMPORT_PENDING = 'CALENDAR_EVENTS_IMPORT_PENDING',
CALENDAR_EVENTS_IMPORT_SCHEDULED = 'CALENDAR_EVENTS_IMPORT_SCHEDULED',
CALENDAR_EVENTS_IMPORT_ONGOING = 'CALENDAR_EVENTS_IMPORT_ONGOING',
FAILED = 'FAILED',
}
export enum CalendarChannelContactAutoCreationPolicy {
AS_PARTICIPANT_AND_ORGANIZER = 'AS_PARTICIPANT_AND_ORGANIZER',
AS_PARTICIPANT = 'AS_PARTICIPANT',
AS_ORGANIZER = 'AS_ORGANIZER',
NONE = 'NONE',
}
export {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
};
registerEnumType(CalendarChannelVisibility, {
name: 'CalendarChannelVisibility',
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { ChannelSyncResolver } from 'src/modules/connected-account/channel-sync/channel-sync.resolver';
@@ -8,6 +10,8 @@ import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-co
@Module({
imports: [
CalendarChannelDataAccessModule,
MessageChannelDataAccessModule,
PermissionsModule,
WorkspaceDataSourceModule,
MessagingCommonModule,
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
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';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -12,13 +14,9 @@ import {
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageChannelSyncStage } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
type MessagingMessageListFetchJobData,
@@ -37,7 +35,9 @@ export class ChannelSyncService {
private readonly messageQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueueService: MessageQueueService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly messageChannelSyncStatusService: MessageChannelSyncStatusService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async startChannelSync(input: StartChannelSyncInput): Promise<void> {
@@ -54,18 +54,13 @@ export class ChannelSyncService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const messageChannels = await messageChannelRepository.find({
where: {
const messageChannels = await this.messageChannelDataAccessService.find(
workspaceId,
{
connectedAccountId,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
});
);
for (const messageChannel of messageChannels) {
await this.messageChannelSyncStatusService.markAsMessagesListFetchScheduled(
@@ -91,25 +86,26 @@ export class ChannelSyncService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
connectedAccountId,
syncStage: CalendarChannelSyncStage.PENDING_CONFIGURATION,
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
connectedAccountId,
syncStage: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
});
);
for (const calendarChannel of calendarChannels) {
await calendarChannelRepository.update(calendarChannel.id, {
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStatus: CalendarChannelSyncStatus.ONGOING,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannel.id },
{
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStatus: CalendarChannelSyncStatus.ONGOING,
},
);
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { DeleteWorkspaceMemberConnectedAccountsCleanupJob } from 'src/modules/connected-account/jobs/delete-workspace-member-connected-accounts.job';
import { ConnectedAccountWorkspaceMemberListener } from 'src/modules/connected-account/listeners/connected-account-workspace-member.listener';
import { ConnectedAccountListener } from 'src/modules/connected-account/listeners/connected-account.listener';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
@Module({
imports: [UserVarsModule],
imports: [ConnectedAccountDataAccessModule, UserVarsModule],
providers: [
AccountsToReconnectService,
ConnectedAccountListener,
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { GmailEmailAliasErrorHandlerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-error-handler.service';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
import { MicrosoftEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/microsoft/services/microsoft-email-alias-manager.service';
@@ -7,7 +8,7 @@ import { EmailAliasManagerService } from 'src/modules/connected-account/email-al
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
@Module({
imports: [OAuth2ClientManagerModule],
imports: [OAuth2ClientManagerModule, ConnectedAccountDataAccessModule],
providers: [
EmailAliasManagerService,
GoogleEmailAliasManagerService,
@@ -1,8 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
import { microsoftGraphMeResponseWithProxyAddresses } from 'src/modules/connected-account/email-alias-manager/drivers/microsoft/mocks/microsoft-api-examples';
@@ -15,30 +15,27 @@ import { EmailAliasManagerService } from './email-alias-manager.service';
describe('Email Alias Manager Service', () => {
let emailAliasManagerService: EmailAliasManagerService;
let microsoftEmailAliasManagerService: MicrosoftEmailAliasManagerService;
let connectedAccountRepository: Partial<
Repository<ConnectedAccountWorkspaceEntity>
>;
const mockConnectedAccountDataAccessService = {
// @ts-expect-error legacy noImplicitAny
update: jest.fn().mockResolvedValue((arg) => arg),
};
beforeEach(async () => {
connectedAccountRepository = {
// @ts-expect-error legacy noImplicitAny
update: jest.fn().mockResolvedValue((arg) => arg),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest
.fn()
.mockResolvedValue(connectedAccountRepository),
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
},
},
EmailAliasManagerService,
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: GoogleEmailAliasManagerService,
useValue: {},
@@ -96,7 +93,8 @@ describe('Email Alias Manager Service', () => {
microsoftEmailAliasManagerService.getHandleAliases,
).toHaveBeenCalledWith(mockConnectedAccount);
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
expect(mockConnectedAccountDataAccessService.update).toHaveBeenCalledWith(
'test-workspace-id',
{ id: mockConnectedAccount.id },
{
handleAliases: expectedAliases,
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
@@ -15,6 +16,7 @@ export class EmailAliasManagerService {
private readonly googleEmailAliasManagerService: GoogleEmailAliasManagerService,
private readonly microsoftEmailAliasManagerService: MicrosoftEmailAliasManagerService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
public async refreshHandleAliases(
@@ -50,13 +52,8 @@ export class EmailAliasManagerService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{ id: connectedAccount.id },
{
handleAliases: handleAliases.join(','), // TODO: modify handleAliases to be of fieldmetadatatype array
@@ -5,6 +5,9 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@@ -19,6 +22,9 @@ import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services
TwentyORMModule,
FeatureFlagModule,
AuthModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MessageChannelDataAccessModule,
],
providers: [ImapSmtpCalDavAPIService],
exports: [ImapSmtpCalDavAPIService],
@@ -1,9 +1,9 @@
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
workspaceId: string;
@@ -14,6 +14,7 @@ export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
@Process(DeleteWorkspaceMemberConnectedAccountsCleanupJob.name)
@@ -25,13 +26,7 @@ export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.delete({
await this.connectedAccountDataAccessService.delete(workspaceId, {
accountOwnerId: workspaceMemberId,
});
}, authContext);
@@ -10,16 +10,16 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@WorkspaceQueryHook(`connectedAccount.destroyOne`)
export class ConnectedAccountDeleteOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@@ -38,13 +38,7 @@ export class ConnectedAccountDeleteOnePreQueryHook
const messageChannels =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspace.id,
'messageChannel',
);
return messageChannelRepository.findBy({
return this.messageChannelDataAccessService.find(workspace.id, {
connectedAccountId,
});
},
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook';
@@ -10,6 +11,7 @@ import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-acc
imports: [
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
MessageChannelDataAccessModule,
],
providers: [ConnectedAccountDeleteOnePreQueryHook],
})
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module';
import { MicrosoftAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/microsoft-api-refresh-access-token.module';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@@ -8,6 +9,7 @@ import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-acco
@Module({
imports: [
JwtModule,
ConnectedAccountDataAccessModule,
GoogleAPIRefreshAccessTokenModule,
MicrosoftAPIRefreshAccessTokenModule,
],
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
@@ -17,7 +18,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
let service: ConnectedAccountRefreshTokensService;
let googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService;
let microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService;
let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
let connectedAccountDataAccessService: ConnectedAccountDataAccessService;
const mockWorkspaceId = 'workspace-123';
const mockConnectedAccountId = 'account-456';
@@ -44,13 +45,18 @@ describe('ConnectedAccountRefreshTokensService', () => {
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest.fn(),
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
},
},
{
provide: ConnectedAccountDataAccessService,
useValue: {
update: jest.fn(),
},
},
],
}).compile();
@@ -65,9 +71,10 @@ describe('ConnectedAccountRefreshTokensService', () => {
module.get<MicrosoftAPIRefreshAccessTokenService>(
MicrosoftAPIRefreshAccessTokenService,
);
globalWorkspaceOrmManager = module.get<GlobalWorkspaceOrmManager>(
GlobalWorkspaceOrmManager,
);
connectedAccountDataAccessService =
module.get<ConnectedAccountDataAccessService>(
ConnectedAccountDataAccessService,
);
});
afterEach(() => {
@@ -96,7 +103,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(globalWorkspaceOrmManager.getRepository).not.toHaveBeenCalled();
expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
});
it('should refresh and save new Microsoft token when expired (lastCredentialsRefreshedAt is old)', async () => {
@@ -108,7 +115,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -117,9 +123,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -130,7 +133,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,
@@ -148,7 +152,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -157,9 +160,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -170,7 +170,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,
@@ -188,7 +189,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: null,
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -197,9 +197,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -210,7 +207,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,

Some files were not shown because too many files have changed in this diff Show More