diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts index ac52eb6ab6d..23b2cdf4655 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts @@ -21,6 +21,9 @@ export const isMatchingSelectFilter = ({ case selectFilter.eq !== undefined: { return value === selectFilter.eq; } + case selectFilter.neq !== undefined: { + return value !== selectFilter.neq; + } default: { throw new Error( `Unexpected value for select filter : ${JSON.stringify(selectFilter)}`, diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx index 0344c573fce..4cb0caa20c4 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsMessageChannelDetails.tsx @@ -101,16 +101,33 @@ export const SettingsAccountsMessageChannelDetails = ({ return ( {isFolderControlEnabled && messageChannel.messageFolders && ( -
- - -
+ <> +
+ + +
+
+ + + handleIsGroupEmailExcludedToggle( + !messageChannel.excludeGroupEmails, + ) + } + /> + +
+ )}
{ handleIsNonProfessionalEmailExcludedToggle( @@ -146,17 +162,6 @@ export const SettingsAccountsMessageChannelDetails = ({ ); }} /> - - handleIsGroupEmailExcludedToggle( - !messageChannel.excludeGroupEmails, - ) - } - />
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts index 77bd3216ae6..ed18dd3a131 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts @@ -10,6 +10,7 @@ import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module'; import { DashboardQueryHookModule } from 'src/modules/dashboard/query-hooks/dashboard-query-hook.module'; import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module'; +import { MessageChannelQueryHookModule } from 'src/modules/messaging/message-channel-manager/query-hooks/message-channel-query-hook.module'; import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module'; @Module({ @@ -19,6 +20,7 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que ConnectedAccountQueryHookModule, DashboardQueryHookModule, BlocklistQueryHookModule, + MessageChannelQueryHookModule, WorkspaceMemberQueryHookModule, DiscoveryModule, ], diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/create-message-channel.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/create-message-channel.service.ts index 6b58f077a88..bd1873ba9fb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/create-message-channel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/create-message-channel.service.ts @@ -1,9 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { MessageChannelSyncStage, MessageChannelSyncStatus, @@ -11,6 +13,7 @@ import { MessageChannelVisibility, type MessageChannelWorkspaceEntity, } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service'; export type CreateMessageChannelInput = { workspaceId: string; @@ -24,6 +27,7 @@ export type CreateMessageChannelInput = { export class CreateMessageChannelService { constructor( private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly syncMessageFoldersService: SyncMessageFoldersService, ) {} async createMessageChannel( @@ -37,6 +41,16 @@ export class CreateMessageChannelService { manager, } = input; + const connectedAccountRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'connectedAccount', + ); + + const connectedAccount = await connectedAccountRepository.findOne({ + where: { id: connectedAccountId }, + }); + const messageChannelRepository = await this.twentyORMGlobalManager.getRepositoryForWorkspace( workspaceId, @@ -58,6 +72,15 @@ export class CreateMessageChannelService { manager, ); + if (isDefined(connectedAccount)) { + await this.syncMessageFoldersService.syncMessageFolders({ + workspaceId, + messageChannelId: newMessageChannel.id, + connectedAccount, + manager, + }); + } + return newMessageChannel.id; } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index e405b0ff0a3..9ba624f8c68 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -247,6 +247,8 @@ export const MESSAGE_CHANNEL_STANDARD_FIELD_IDS = { messageChannelMessageAssociations: '20202020-49b8-4766-88fd-75f1e21b3d5f', messageFolders: '20202020-cc39-4432-9fe8-ec8ab8bbed94', messageFolderImportPolicy: '20202020-cc39-4432-9fe8-ec8ab8bbed95', + syncAllFolders: '20202020-e36b-409c-a9bd-79071596cdc0', + pendingGroupEmailsAction: '20202020-17c5-4e9f-bc50-af46a89fdd42', isSyncEnabled: '20202020-d9a6-48e9-990b-b97fdf22e8dd', syncCursor: '20202020-79d1-41cf-b738-bcf5ed61e256', syncedAt: '20202020-263d-4c6b-ad51-137ada56f7d4', @@ -263,6 +265,7 @@ export const MESSAGE_FOLDER_STANDARD_FIELD_IDS = { isSentFolder: '20202020-2af5-4a25-b2de-3c9386da941b', isSynced: '20202020-764f-4e09-8f95-cd46b6bfe3c4', externalId: '20202020-f3a8-4d2b-9c7e-1b5f9a8e4c6d', + pendingSyncAction: '20202020-4f97-4c79-9517-16387fe237f7', } as const; export const MESSAGE_PARTICIPANT_STANDARD_FIELD_IDS = { diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts index 7ecc7dd4266..17a9ba0517b 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-channel.workspace-entity.ts @@ -65,6 +65,12 @@ export enum MessageFolderImportPolicy { SELECTED_FOLDERS = 'SELECTED_FOLDERS', } +export enum MessageChannelPendingGroupEmailsAction { + GROUP_EMAILS_DELETION = 'GROUP_EMAILS_DELETION', + GROUP_EMAILS_IMPORT = 'GROUP_EMAILS_IMPORT', + NONE = 'NONE', +} + registerEnumType(MessageChannelVisibility, { name: 'MessageChannelVisibility', }); @@ -89,6 +95,10 @@ registerEnumType(MessageFolderImportPolicy, { name: 'MessageFolderImportPolicy', }); +registerEnumType(MessageChannelPendingGroupEmailsAction, { + name: 'MessageChannelPendingGroupEmailsAction', +}); + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageChannel, namePlural: 'messageChannels', @@ -249,6 +259,46 @@ export class MessageChannelWorkspaceEntity extends BaseWorkspaceEntity { }) excludeGroupEmails: boolean; + @WorkspaceField({ + standardId: MESSAGE_CHANNEL_STANDARD_FIELD_IDS.syncAllFolders, + type: FieldMetadataType.BOOLEAN, + label: msg`Sync all folders`, + description: msg`Sync all folders including new ones`, + icon: 'IconFolders', + defaultValue: true, + }) + syncAllFolders: boolean; + + @WorkspaceField({ + standardId: MESSAGE_CHANNEL_STANDARD_FIELD_IDS.pendingGroupEmailsAction, + type: FieldMetadataType.SELECT, + label: msg`Pending group emails action`, + description: msg`Pending action for group emails`, + icon: 'IconUsersGroup', + options: [ + { + value: MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_DELETION, + label: 'Group emails deletion', + position: 0, + color: 'red', + }, + { + value: MessageChannelPendingGroupEmailsAction.GROUP_EMAILS_IMPORT, + label: 'Group emails import', + position: 1, + color: 'green', + }, + { + value: MessageChannelPendingGroupEmailsAction.NONE, + label: 'None', + position: 2, + color: 'blue', + }, + ], + defaultValue: `'${MessageChannelPendingGroupEmailsAction.NONE}'`, + }) + pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction; + @WorkspaceField({ standardId: MESSAGE_CHANNEL_STANDARD_FIELD_IDS.isSyncEnabled, type: FieldMetadataType.BOOLEAN, diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts index 0505319f6bc..e8ab95b10de 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts @@ -1,3 +1,5 @@ +import { registerEnumType } from '@nestjs/graphql'; + import { msg } from '@lingui/core/macro'; import { FieldMetadataType } from 'twenty-shared/types'; import { Relation } from 'typeorm'; @@ -18,6 +20,16 @@ import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sy import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +export enum MessageFolderPendingSyncAction { + FOLDER_DELETION = 'FOLDER_DELETION', + FOLDER_IMPORT = 'FOLDER_IMPORT', + NONE = 'NONE', +} + +registerEnumType(MessageFolderPendingSyncAction, { + name: 'MessageFolderPendingSyncAction', +}); + @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.messageFolder, namePlural: 'messageFolders', @@ -90,6 +102,36 @@ export class MessageFolderWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceIsNullable() externalId: string | null; + @WorkspaceField({ + standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.pendingSyncAction, + type: FieldMetadataType.SELECT, + label: msg`Pending Sync Action`, + description: msg`Pending action for folder sync`, + icon: 'IconReload', + options: [ + { + value: MessageFolderPendingSyncAction.FOLDER_DELETION, + label: 'Folder deletion', + position: 0, + color: 'red', + }, + { + value: MessageFolderPendingSyncAction.FOLDER_IMPORT, + label: 'Folder import', + position: 1, + color: 'green', + }, + { + value: MessageFolderPendingSyncAction.NONE, + label: 'None', + position: 2, + color: 'blue', + }, + ], + defaultValue: `'${MessageFolderPendingSyncAction.NONE}'`, + }) + pendingSyncAction: MessageFolderPendingSyncAction; + @WorkspaceJoinColumn('messageChannel') messageChannelId: string; } diff --git a/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-query-hook.module.ts b/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-query-hook.module.ts new file mode 100644 index 00000000000..00e2dfe18d5 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-query-hook.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { MessageChannelUpdateOnePreQueryHook } from 'src/modules/messaging/message-channel-manager/query-hooks/message-channel-update-one.pre-query.hook'; + +@Module({ + providers: [MessageChannelUpdateOnePreQueryHook], + exports: [MessageChannelUpdateOnePreQueryHook], +}) +export class MessageChannelQueryHookModule {} diff --git a/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-update-one.pre-query.hook.ts new file mode 100644 index 00000000000..51a8c26fa42 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-channel-manager/query-hooks/message-channel-update-one.pre-query.hook.ts @@ -0,0 +1,106 @@ +import { msg } from '@lingui/core/macro'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; +import { Not } from 'typeorm'; + +import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; +import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; +import { + WorkspaceQueryRunnerException, + WorkspaceQueryRunnerExceptionCode, +} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { + MessageChannelPendingGroupEmailsAction, + MessageChannelSyncStage, + type MessageChannelWorkspaceEntity, +} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; +import { + MessageFolderPendingSyncAction, + type MessageFolderWorkspaceEntity, +} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity'; + +const ONGOING_SYNC_STAGES = [ + MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING, + MessageChannelSyncStage.MESSAGES_IMPORT_ONGOING, +]; + +@WorkspaceQueryHook(`messageChannel.updateOne`) +export class MessageChannelUpdateOnePreQueryHook + implements WorkspacePreQueryHookInstance +{ + constructor( + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + async execute( + authContext: AuthContext, + _objectName: string, + payload: UpdateOneResolverArgs, + ): Promise> { + const workspace = authContext.workspace; + + assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); + + const messageChannelRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspace.id, + 'messageChannel', + ); + + const messageChannel = await messageChannelRepository.findOne({ + where: { id: payload.id }, + }); + + if (!messageChannel) { + throw new WorkspaceQueryRunnerException( + 'Message channel not found', + WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND, + { + userFriendlyMessage: msg`Message channel not found`, + }, + ); + } + + const isSyncOngoing = ONGOING_SYNC_STAGES.includes( + messageChannel.syncStage, + ); + + const messageFolderRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspace.id, + 'messageFolder', + ); + + const folderWithPendingAction = await messageFolderRepository.findOne({ + where: { + messageChannelId: messageChannel.id, + pendingSyncAction: Not(MessageFolderPendingSyncAction.NONE), + }, + }); + + const hasPendingFolderActions = !!folderWithPendingAction; + + const hasPendingGroupEmailsAction = + messageChannel.pendingGroupEmailsAction !== + MessageChannelPendingGroupEmailsAction.NONE; + + if ( + isSyncOngoing && + (hasPendingFolderActions || hasPendingGroupEmailsAction) + ) { + throw new WorkspaceQueryRunnerException( + 'Cannot update message channel while sync is ongoing with pending actions', + WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { + userFriendlyMessage: msg`Cannot update message channel while sync is ongoing. Please wait for the sync to complete.`, + }, + ); + } + + return payload; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts index bb50d8dc4a7..d4081e6fc79 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts @@ -81,13 +81,19 @@ export class ImapGetAllFoldersService implements MessageFolderDriver { for (const mailbox of validMailboxes) { const isInbox = await this.isInboxFolder(mailbox); const uidValidity = await this.getUidValidity(client, mailbox); + const standardFolder = getStandardFolderByRegex(mailbox.path); + const isSynced = this.shouldSyncByDefault( + mailbox, + standardFolder, + isInbox, + ); folders.push({ externalId: uidValidity ? `${mailbox.path}:${uidValidity}` : mailbox.path, name: mailbox.path, - isSynced: isInbox, + isSynced, isSentFolder: false, }); } @@ -126,23 +132,35 @@ export class ImapGetAllFoldersService implements MessageFolderDriver { return true; } + return false; + } + + private shouldSyncByDefault( + mailbox: ListResponse, + standardFolder: StandardFolder | null, + isInbox: boolean, + ): boolean { if ( mailbox.specialUse === '\\Drafts' || mailbox.specialUse === '\\Trash' || mailbox.specialUse === '\\Junk' ) { - return true; - } - const standardFolder = getStandardFolderByRegex(mailbox.path); - - if (!standardFolder) { return false; } - return ( - standardFolder !== StandardFolder.SENT && - standardFolder !== StandardFolder.INBOX - ); + if ( + standardFolder === StandardFolder.DRAFTS || + standardFolder === StandardFolder.TRASH || + standardFolder === StandardFolder.JUNK + ) { + return false; + } + + if (isInbox) { + return true; + } + + return false; } private async getUidValidity( diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts index b13209a1b25..eb83e33068f 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts @@ -67,18 +67,13 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver { } const standardFolder = getStandardFolderByRegex(folder.displayName); - - if (this.shouldExcludeFolder(standardFolder)) { - continue; - } - - const isInbox = this.isInboxFolder(standardFolder); const isSentFolder = this.isSentFolder(standardFolder); + const isSynced = this.shouldSyncByDefault(standardFolder); folderInfos.push({ externalId: folder.id, name: folder.displayName, - isSynced: isInbox || isSentFolder, + isSynced, isSentFolder, }); } @@ -98,19 +93,19 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver { } } - private isInboxFolder(standardFolder: StandardFolder | null): boolean { - return standardFolder === StandardFolder.INBOX; - } - private isSentFolder(standardFolder: StandardFolder | null): boolean { return standardFolder === StandardFolder.SENT; } - private shouldExcludeFolder(standardFolder: StandardFolder | null): boolean { - return ( - standardFolder !== null && - standardFolder !== StandardFolder.SENT && - standardFolder !== StandardFolder.INBOX - ); + private shouldSyncByDefault(standardFolder: StandardFolder | null): boolean { + if ( + standardFolder === StandardFolder.JUNK || + standardFolder === StandardFolder.DRAFTS || + standardFolder === StandardFolder.TRASH + ) { + return false; + } + + return true; } } diff --git a/packages/twenty-server/src/modules/messaging/messaging.module.ts b/packages/twenty-server/src/modules/messaging/messaging.module.ts index 698f1ef999c..afc27cf2f25 100644 --- a/packages/twenty-server/src/modules/messaging/messaging.module.ts +++ b/packages/twenty-server/src/modules/messaging/messaging.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { MessagingBlocklistManagerModule } from 'src/modules/messaging/blocklist-manager/messaging-blocklist-manager.module'; +import { MessageChannelQueryHookModule } from 'src/modules/messaging/message-channel-manager/query-hooks/message-channel-query-hook.module'; import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module'; @@ -13,6 +14,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess MessageParticipantManagerModule, MessagingBlocklistManagerModule, MessagingMonitoringModule, + MessageChannelQueryHookModule, ], providers: [], exports: [MessagingImportManagerModule], diff --git a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts index 78ce99b03af..e30d8e9cce6 100644 --- a/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-shared/src/types/RecordGqlOperationFilter.ts @@ -114,6 +114,7 @@ export type SelectFilter = { is?: IsFilter; in?: string[]; eq?: string; + neq?: string; }; export type MultiSelectFilter = {